Template
1
0

feat: refactor account

This commit is contained in:
2025-08-12 05:24:20 +02:00
parent 1215a98afc
commit f0630d43b7
25 changed files with 256 additions and 332 deletions

View File

@@ -1,10 +1,10 @@
import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store";
import { Avatar, Contact, Email, Name, Phone, Strategy } from "relay/schemas";
import { Strategy } from "@spec/modules/account/strategies.ts";
import { Avatar, Contact, Email, Name } from "@spec/shared";
import { AggregateRoot, getDate } from "@valkyr/event-store";
import { db, toAccountDriver } from "~libraries/read-store/mod.ts";
import { db } from "~libraries/read-store/mod.ts";
import { eventStore } from "../event-store.ts";
import { AccountCreatedData } from "../events/account.ts";
import { Auditor } from "../events/auditor.ts";
import { EventStoreFactory } from "../events/mod.ts";
import { projector } from "../projector.ts";
@@ -12,69 +12,22 @@ import { projector } from "../projector.ts";
export class Account extends AggregateRoot<EventStoreFactory> {
static override readonly name = "account";
id!: string;
organizationId?: string;
type!: "admin" | "consultant" | "organization";
avatar?: Avatar;
name?: Name;
contact: Contact = {
emails: [],
phones: [],
};
strategies: Strategy[] = [];
createdAt!: Date;
updatedAt!: Date;
// -------------------------------------------------------------------------
// Factories
// -------------------------------------------------------------------------
static #reducer = makeAggregateReducer(Account);
static create(data: AccountCreatedData, meta: Auditor): Account {
return new Account().push({
type: "account:created",
data,
meta,
});
}
static async getById(stream: string): Promise<Account | undefined> {
return this.$store.reduce({ name: "account", stream, reducer: this.#reducer });
}
static async getByEmail(email: string): Promise<Account | undefined> {
return this.$store.reduce({ name: "account", relation: Account.emailRelation(email), reducer: this.#reducer });
}
// -------------------------------------------------------------------------
// Relations
// -------------------------------------------------------------------------
static emailRelation(email: string): `account:email:${string}` {
return `account:email:${email}`;
}
static passwordRelation(alias: string): `account:password:${string}` {
return `account:password:${alias}`;
}
// -------------------------------------------------------------------------
// Reducer
// -------------------------------------------------------------------------
with(event: EventStoreFactory["$events"][number]["$record"]): void {
switch (event.type) {
case "account:created": {
this.id = event.stream;
this.organizationId = event.data.type === "organization" ? event.data.organizationId : undefined;
this.type = event.data.type;
this.createdAt = getDate(event.created);
break;
}
case "account:avatar:added": {
this.avatar = { url: event.data };
this.updatedAt = getDate(event.created);
@@ -90,11 +43,6 @@ export class Account extends AggregateRoot<EventStoreFactory> {
this.updatedAt = getDate(event.created);
break;
}
case "account:phone:added": {
this.contact.phones.push(event.data);
this.updatedAt = getDate(event.created);
break;
}
case "strategy:email:added": {
this.strategies.push({ type: "email", value: event.data });
this.updatedAt = getDate(event.created);
@@ -139,15 +87,6 @@ export class Account extends AggregateRoot<EventStoreFactory> {
});
}
addPhone(phone: Phone, meta: Auditor): this {
return this.push({
stream: this.id,
type: "account:phone:added",
data: phone,
meta,
});
}
addRole(roleId: string, meta: Auditor): this {
return this.push({
stream: this.id,
@@ -174,95 +113,40 @@ export class Account extends AggregateRoot<EventStoreFactory> {
meta,
});
}
// -------------------------------------------------------------------------
// Utilities
// -------------------------------------------------------------------------
toSession(): Session {
if (this.type === "organization") {
if (this.organizationId === undefined) {
throw new Error("Account .toSession failed, no organization id present");
}
return {
type: this.type,
accountId: this.id,
organizationId: this.organizationId,
};
}
return {
type: this.type,
accountId: this.id,
};
}
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
type Session =
| {
type: "organization";
accountId: string;
organizationId: string;
}
| {
type: "admin" | "consultant";
accountId: string;
};
/*
|--------------------------------------------------------------------------------
| Projectors
|--------------------------------------------------------------------------------
*/
projector.on("account:created", async ({ stream, data }) => {
const schema: any = {
id: stream,
type: data.type,
contact: {
emails: [],
phones: [],
},
strategies: [],
roles: [],
};
if (data.type === "organization") {
schema.organizationId = data.organizationId;
}
await db.collection("accounts").insertOne(toAccountDriver(schema));
});
projector.on("account:avatar:added", async ({ stream: id, data: url }) => {
await db.collection("accounts").updateOne({ id }, { $set: { avatar: { url } } });
await db.collection("accounts").updateOne({ id }, { $set: { avatar: { url } } }, { upsert: true });
});
projector.on("account:name:added", async ({ stream: id, data: name }) => {
await db.collection("accounts").updateOne({ id }, { $set: { name } });
await db.collection("accounts").updateOne({ id }, { $set: { name } }, { upsert: true });
});
projector.on("account:email:added", async ({ stream: id, data: email }) => {
await db.collection("accounts").updateOne({ id }, { $push: { "contact.emails": email } });
});
projector.on("account:phone:added", async ({ stream: id, data: phone }) => {
await db.collection("accounts").updateOne({ id }, { $push: { "contact.phones": phone } });
await db.collection("accounts").updateOne({ id }, { $push: { "contact.emails": email } }, { upsert: true });
});
projector.on("account:role:added", async ({ stream: id, data: roleId }) => {
await db.collection("accounts").updateOne({ id }, { $push: { roles: roleId } });
await db.collection("accounts").updateOne({ id }, { $push: { roles: roleId } }, { upsert: true });
});
projector.on("strategy:email:added", async ({ stream: id, data: email }) => {
await eventStore.relations.insert(Account.emailRelation(email), id);
await db.collection("accounts").updateOne({ id }, { $push: { strategies: { type: "email", value: email } } });
await eventStore.relations.insert(`account:email:${email}`, id);
await db
.collection("accounts")
.updateOne({ id }, { $push: { strategies: { type: "email", value: email } } }, { upsert: true });
});
projector.on("strategy:password:added", async ({ stream: id, data: strategy }) => {
await eventStore.relations.insert(Account.passwordRelation(strategy.alias), id);
await db.collection("accounts").updateOne({ id }, { $push: { strategies: { type: "password", ...strategy } } });
await eventStore.relations.insert(`account:alias:${strategy.alias}`, id);
await db
.collection("accounts")
.updateOne({ id }, { $push: { strategies: { type: "password", ...strategy } } }, { upsert: true });
});

View File

@@ -1,8 +0,0 @@
import { AggregateFactory } from "@valkyr/event-store";
import { Account } from "./account.ts";
import { Code } from "./code.ts";
import { Organization } from "./organization.ts";
import { Role } from "./role.ts";
export const aggregates = new AggregateFactory([Account, Code, Organization, Role]);

View File

@@ -1,29 +1,12 @@
import { EmailSchema, NameSchema } from "@spec/shared";
import { event } from "@valkyr/event-store";
import { email, name, phone } from "relay/schemas";
import z from "zod";
import { auditor } from "./auditor.ts";
const created = z.discriminatedUnion([
z.object({
type: z.literal("admin"),
}),
z.object({
type: z.literal("consultant"),
}),
z.object({
type: z.literal("organization"),
organizationId: z.string(),
}),
]);
export default [
event.type("account:created").data(created).meta(auditor),
event.type("account:avatar:added").data(z.string()).meta(auditor),
event.type("account:name:added").data(name).meta(auditor),
event.type("account:email:added").data(email).meta(auditor),
event.type("account:phone:added").data(phone).meta(auditor),
event.type("account:name:added").data(NameSchema).meta(auditor),
event.type("account:email:added").data(EmailSchema).meta(auditor),
event.type("account:role:added").data(z.string()).meta(auditor),
];
export type AccountCreatedData = z.infer<typeof created>;

View File

@@ -3,7 +3,7 @@ import z from "zod";
import { auditor } from "./auditor.ts";
const created = z.object({
const CreatedSchema = z.object({
name: z.string(),
permissions: z.array(
z.object({
@@ -13,7 +13,7 @@ const created = z.object({
),
});
const operation = z.discriminatedUnion([
const OperationSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("grant"),
resource: z.string(),
@@ -27,11 +27,11 @@ const operation = z.discriminatedUnion([
]);
export default [
event.type("role:created").data(created).meta(auditor),
event.type("role:created").data(CreatedSchema).meta(auditor),
event.type("role:name-set").data(z.string()).meta(auditor),
event.type("role:permissions-set").data(z.array(operation)).meta(auditor),
event.type("role:permissions-set").data(z.array(OperationSchema)).meta(auditor),
];
export type RoleCreatedData = z.infer<typeof created>;
export type RoleCreatedData = z.infer<typeof CreatedSchema>;
export type RolePermissionOperation = z.infer<typeof operation>;
export type RolePermissionOperation = z.infer<typeof OperationSchema>;

View File

@@ -1,6 +0,0 @@
import { db, takeOne } from "../database.ts";
import { type AccountSchema, fromAccountDriver } from "./schema.ts";
export async function getAccountById(id: string): Promise<AccountSchema | undefined> {
return db.collection("accounts").find({ id }).toArray().then(fromAccountDriver).then(takeOne);
}

View File

@@ -1,36 +0,0 @@
import { z } from "zod";
const account = z.object({
id: z.uuid(),
name: z.object({
given: z.string(),
family: z.string(),
}),
email: z.email(),
});
/*
|--------------------------------------------------------------------------------
| Parsers
|--------------------------------------------------------------------------------
*/
const select = account;
const insert = account;
export function toAccountDriver(documents: unknown): AccountInsert {
return insert.parse(documents);
}
export function fromAccountDriver(documents: unknown[]): AccountSchema[] {
return documents.map((document) => select.parse(document));
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
export type AccountSchema = z.infer<typeof select>;
export type AccountInsert = z.infer<typeof insert>;

View File

@@ -1,10 +1,10 @@
import type { AccountDocument } from "@spec/modules/account/account.ts";
import { config } from "~config";
import { getDatabaseAccessor } from "~libraries/database/accessor.ts";
import { AccountInsert } from "./account/schema.ts";
export const db = getDatabaseAccessor<{
accounts: AccountInsert;
accounts: AccountDocument;
}>(`${config.name}:read-store`);
export function takeOne<TDocument>(documents: TDocument[]): TDocument | undefined {

View File

@@ -0,0 +1,35 @@
import { type Account, parseAccount } from "@spec/modules/account/account.ts";
import { db, takeOne } from "./database.ts";
/*
|--------------------------------------------------------------------------------
| Accounts
|--------------------------------------------------------------------------------
*/
/**
* Retrieve a single account by its primary identifier.
*
* @param id - Account identifier.
*/
export async function getAccountById(id: string): Promise<Account | undefined> {
return db
.collection("accounts")
.aggregate([
{
$match: { id },
},
{
$lookup: {
from: "roles",
localField: "roles",
foreignField: "id",
as: "roles",
},
},
])
.toArray()
.then(parseAccount)
.then(takeOne);
}

View File

@@ -1,3 +1,2 @@
export * from "./account/methods.ts";
export * from "./account/schema.ts";
export * from "./database.ts";
export * from "./methods.ts";

View File

@@ -14,7 +14,7 @@
"@std/fs": "npm:@jsr/std__fs@1",
"@std/path": "npm:@jsr/std__path@1",
"@valkyr/auth": "npm:@jsr/valkyr__auth@2",
"@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.0-beta.5",
"@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.0-beta.6",
"@valkyr/inverse": "npm:@jsr/valkyr__inverse@1",
"cookie": "1",
"mongodb": "6",