diff --git a/.gitignore b/.gitignore index 2d77557f..676e9c27 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .aider* .DS_Store -node_modules \ No newline at end of file +node_modules +dist/ \ No newline at end of file diff --git a/docs/working-notes.md b/docs/working-notes.md new file mode 100644 index 00000000..67aa7440 --- /dev/null +++ b/docs/working-notes.md @@ -0,0 +1,14 @@ +# 2025.04.18 + +- consider better abstractions for validating annotation values +- consider better error handling abstractions and avoid returning null values +- default values in templates - how to think of this? +- after an annotation is published to atproto, the repo should be updated with the id of that record + +# 2025.04.17 + +- consider creating annotations from a templated context + - this means multiple annotations will be created at the same time, so we should have a use-case for that + - CreateAnnotationsFromTemplate - so we can properly handle this all as one request and one transaction +- consider how we want to decouple the annotation creation from the annotation publishing + - should it all happen as one, or dispatch an `AnnotationsCreatedFromTemplate` event and then handle that event which will them interact with at proto and manage all of that logic diff --git a/package-lock.json b/package-lock.json index 8f08c5ee..cf0436f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,13 +18,16 @@ "@atproto/syntax": "^0.4.0", "@atproto/xrpc-server": "^0.7.15", "dotenv": "^16.5.0", - "express": "^5.1.0" + "express": "^5.1.0", + "uuid": "^11.1.0" }, "devDependencies": { "@atproto/lex-cli": "^0.8.0", "@types/express": "^5.0.1", "@types/jest": "^29.5.14", + "drizzle-orm": "^0.32.1", "jest": "^29.7.0", + "pg": "^8.12.0", "typescript": "^5.8.3" } }, @@ -2587,6 +2590,127 @@ "url": "https://dotenvx.com" } }, + "node_modules/drizzle-orm": { + "version": "0.32.2", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.32.2.tgz", + "integrity": "sha512-3fXKzPzrgZIcnWCSLiERKN5Opf9Iagrag75snfFlKeKSYB1nlgPBshzW3Zn6dQymkyiib+xc4nIz0t8U+Xdpuw==", + "dev": true, + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=3", + "@electric-sql/pglite": ">=0.1.1", + "@libsql/client": "*", + "@neondatabase/serverless": ">=0.1", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/react": ">=18", + "@types/sql.js": "*", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=13.2.0", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "react": ">=18", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4465,6 +4589,95 @@ "node": ">=16" } }, + "node_modules/pg": { + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.14.1.tgz", + "integrity": "sha512-0TdbqfjwIun9Fm/r89oB7RFQ0bLgduAhiIqIXOsyKoiC/L54DbuAAzIEN/9Op0f1Po9X7iCPXGoa/Ah+2aI8Xw==", + "dev": true, + "dependencies": { + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.8.0", + "pg-protocol": "^1.8.0", + "pg-types": "^2.1.0", + "pgpass": "1.x" + }, + "engines": { + "node": ">= 8.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.1.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", + "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "dev": true, + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==", + "dev": true + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.8.0.tgz", + "integrity": "sha512-VBw3jiVm6ZOdLBTIcXLNdSotb6Iy3uOCwDGFAksZCXmi10nyRvnP2v3jl4d+IsLYRyXf6o9hIm/ZtUzlByNUdw==", + "dev": true, + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.8.0.tgz", + "integrity": "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g==", + "dev": true + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4539,6 +4752,45 @@ "node": ">=8" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prettier": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", @@ -5424,6 +5676,18 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -5530,6 +5794,15 @@ } } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 98931112..464b2f83 100644 --- a/package.json +++ b/package.json @@ -9,22 +9,23 @@ "scripts": { "test": "jest", "build:watch": "tsc --watch", - "lexgen": "lex gen-server ./src/lexicon ./lexicons/*" + "lexgen": "lex gen-server ./src/modules/atproto/infrastructure/lexicon ./lexicons/*" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "@atproto/api": "^0.15.2", + "@atproto/common": "^0.4.10", "@atproto/identity": "^0.4.7", "@atproto/lexicon": "^0.4.10", "@atproto/oauth-client-node": "^0.2.15", "@atproto/sync": "^0.1.20", "@atproto/syntax": "^0.4.0", "@atproto/xrpc-server": "^0.7.15", - "@atproto/common": "^0.4.10", "dotenv": "^16.5.0", - "express": "^5.1.0" + "express": "^5.1.0", + "uuid": "^11.1.0" }, "devDependencies": { "@atproto/lex-cli": "^0.8.0", diff --git a/src/annotations/application/repositories/IAnnotationFieldRepository.ts b/src/annotations/application/repositories/IAnnotationFieldRepository.ts deleted file mode 100644 index eb61a0bd..00000000 --- a/src/annotations/application/repositories/IAnnotationFieldRepository.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { AnnotationField } from '../../domain/aggregates/AnnotationField' -import { TID } from '../../../atproto/domain/value-objects/TID' - -// Placeholder Interface for AnnotationField Repository -export interface IAnnotationFieldRepository { - findById(id: TID): Promise - findByUri(uri: string): Promise // Find by AT URI - findByName(name: string): Promise // Example query - save(field: AnnotationField): Promise - delete(id: TID): Promise - // Add other query methods as needed -} diff --git a/src/annotations/application/repositories/IAnnotationRepository.ts b/src/annotations/application/repositories/IAnnotationRepository.ts deleted file mode 100644 index 885788d2..00000000 --- a/src/annotations/application/repositories/IAnnotationRepository.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Annotation } from '../../domain/aggregates/Annotation' -import { TID } from '../../../atproto/domain/value-objects/TID' -import { URI } from '../../domain/value-objects/URI' - -// Placeholder Interface for Annotation Repository -export interface IAnnotationRepository { - findById(id: TID): Promise - findByUri(uri: string): Promise // Find by AT URI - findByUrl(url: URI): Promise // Find by annotated resource URL - save(annotation: Annotation): Promise - delete(id: TID): Promise - // Add other query methods as needed -} diff --git a/src/annotations/application/repositories/IAnnotationTemplateRepository.ts b/src/annotations/application/repositories/IAnnotationTemplateRepository.ts deleted file mode 100644 index 58b3d5bc..00000000 --- a/src/annotations/application/repositories/IAnnotationTemplateRepository.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { AnnotationTemplate } from '../../domain/aggregates/AnnotationTemplate' -import { TID } from '../../../atproto/domain/value-objects/TID' - -// Placeholder Interface for AnnotationTemplate Repository -export interface IAnnotationTemplateRepository { - findById(id: TID): Promise - findByUri(uri: string): Promise // Find by AT URI - findByName(name: string): Promise // Example query - save(template: AnnotationTemplate): Promise - delete(id: TID): Promise - // Add other query methods as needed -} diff --git a/src/annotations/domain/aggregates/Annotation.ts b/src/annotations/domain/aggregates/Annotation.ts deleted file mode 100644 index c5654462..00000000 --- a/src/annotations/domain/aggregates/Annotation.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { StrongRef } from '../../../atproto/domain/value-objects/StrongRef' -import { Identifier } from '../value-objects/Identifier' -import { AnnotationValue } from '../value-objects/AnnotationValue' -import { URI } from '../value-objects/URI' -import { TID } from '../../../atproto/domain/value-objects/TID' - -// Properties required to construct an Annotation -export interface AnnotationProps { - id: TID - url: URI - fieldRef: StrongRef - value: AnnotationValue - additionalIdentifiers?: Identifier[] - templateRefs?: StrongRef[] - note?: string - createdAt: Date -} - -// Properties required to create a new Annotation (ID and createdAt are generated) -export type AnnotationCreateProps = Omit & { - id?: TID // Allow providing an ID optionally -} - -// Placeholder for Annotation Aggregate Root -export class Annotation { - readonly id: TID - readonly url: URI - readonly fieldRef: StrongRef // Reference to AnnotationField - readonly value: AnnotationValue - readonly additionalIdentifiers?: Identifier[] - readonly templateRefs?: StrongRef[] // Reference to AnnotationTemplate(s) - readonly note?: string - readonly createdAt: Date - - // Private constructor to enforce creation via factory or repository - private constructor(props: AnnotationProps) { - this.id = props.id - this.url = props.url - this.fieldRef = props.fieldRef - this.value = props.value - this.additionalIdentifiers = props.additionalIdentifiers - this.templateRefs = props.templateRefs - this.note = props.note - this.createdAt = props.createdAt - - // TODO: Add validation logic here or in a factory - // - Ensure value type matches field definition (requires fetching field) - } - - // Factory method for creating new Annotation instances - public static create(props: AnnotationCreateProps): Annotation { - const id = props.id ?? TID.create() // Generate TID if not provided - const createdAt = new Date() // Set creation timestamp - - // TODO: Add complex validation logic, potentially involving fetching the AnnotationField - // to ensure the value type is compatible with the field definition. - // This validation might be better suited for an Application Service/UseCase - // that has access to repositories. - - // Construct the full props object for the private constructor - const constructorProps: AnnotationProps = { - ...props, // Spread properties from create input (url, fieldRef, value, etc.) - id, // Use the generated or provided ID - createdAt, // Use the generated timestamp - } - - return new Annotation(constructorProps) - } - - // Methods for business logic related to Annotation -} diff --git a/src/annotations/domain/aggregates/AnnotationTemplate.ts b/src/annotations/domain/aggregates/AnnotationTemplate.ts deleted file mode 100644 index b4dbfba7..00000000 --- a/src/annotations/domain/aggregates/AnnotationTemplate.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { TemplateField } from '../value-objects/TemplateField' -import { TID } from '../../../atproto/domain/value-objects/TID' - -// Properties required to construct an AnnotationTemplate -export interface AnnotationTemplateProps { - id: TID - name: string - description: string - annotationFields: TemplateField[] - createdAt: Date -} - -// Properties required to create a new AnnotationTemplate -export type AnnotationTemplateCreateProps = Omit< - AnnotationTemplateProps, - 'id' | 'createdAt' -> & { - id?: TID // Allow providing an ID optionally -} - -// Placeholder for AnnotationTemplate Aggregate Root -export class AnnotationTemplate { - readonly id: TID - readonly name: string - readonly description: string - readonly annotationFields: TemplateField[] - readonly createdAt: Date - - private constructor(props: AnnotationTemplateProps) { - this.id = props.id - this.name = props.name - this.description = props.description - this.annotationFields = props.annotationFields - this.createdAt = props.createdAt - - // TODO: Add more validation logic here if needed - } - - public static create(props: AnnotationTemplateCreateProps): AnnotationTemplate { - const id = props.id ?? TID.create() - const createdAt = new Date() - - // TODO: Add validation - if (!props.name || props.name.trim().length === 0) { - throw new Error('AnnotationTemplate name cannot be empty.') - } - if (!props.annotationFields || props.annotationFields.length === 0) { - throw new Error('AnnotationTemplate must include at least one field.') - } - // Add more validation as needed (e.g., description length) - - const constructorProps: AnnotationTemplateProps = { - ...props, - id, - createdAt, - } - - return new AnnotationTemplate(constructorProps) - } - - // Method to add a field (example of behavior) - public addField(field: TemplateField): void { - // TODO: Add logic to prevent duplicates, etc. - this.annotationFields.push(field) - } - - // Method to remove a field - public removeField(fieldRefUri: string): void { - // TODO: Implement removal logic - const index = this.annotationFields.findIndex( - (f) => f.fieldRef.uri === fieldRefUri, - ) - if (index > -1) { - this.annotationFields.splice(index, 1) - } - } -} diff --git a/src/annotations/domain/value-objects/TemplateField.ts b/src/annotations/domain/value-objects/TemplateField.ts deleted file mode 100644 index 3ba730ef..00000000 --- a/src/annotations/domain/value-objects/TemplateField.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { StrongRef } from '../../../atproto/domain/value-objects/StrongRef' - -// Placeholder for TemplateField Value Object -export class TemplateField { - readonly $type = 'app.annos.annotationTemplate#annotationFieldRef' // Match lexicon def - readonly fieldRef: StrongRef // Reference to AnnotationField - readonly required: boolean - - constructor(fieldRef: StrongRef, required: boolean = false) { - this.fieldRef = fieldRef - this.required = required - // TODO: Add validation if needed - } -} diff --git a/src/atproto/domain/value-objects/TID.ts b/src/atproto/domain/value-objects/TID.ts index e792cdcb..dc80ae13 100644 --- a/src/atproto/domain/value-objects/TID.ts +++ b/src/atproto/domain/value-objects/TID.ts @@ -1,34 +1,27 @@ -import { TID as LexTID } from '@atproto/common' // Using the official library for generation/validation +import { TID as LexTID } from "@atproto/common"; // Using the official library for generation/validation // Placeholder for TID Value Object // Wraps the @atproto/common TID class for domain modeling purposes export class TID { - private readonly tid: LexTID + private readonly tid: LexTID; private constructor(tid: LexTID) { - this.tid = tid + this.tid = tid; } public static fromString(str: string): TID { try { - return new TID(LexTID.fromStr(str)) + return new TID(LexTID.fromStr(str)); } catch (e) { - throw new Error(`Invalid TID format: ${str}`, { cause: e }) + throw new Error(`Invalid TID format: ${str}`, { cause: e }); } } - public static create(clockid?: number): TID { - // Clock ID should ideally be unique per PDS/generator instance - // Using a random one here for simplicity, but should be configured - const effectiveClockId = clockid ?? Math.floor(Math.random() * 1024) - return new TID(LexTID.next(effectiveClockId)) - } - public toString(): string { - return this.tid.toString() + return this.tid.toString(); } public equals(other: TID): boolean { - return this.tid.toString() === other.tid.toString() + return this.tid.toString() === other.tid.toString(); } } diff --git a/src/lexicon/index.ts b/src/atproto/infrastructure/lexicon/index.ts similarity index 100% rename from src/lexicon/index.ts rename to src/atproto/infrastructure/lexicon/index.ts diff --git a/src/lexicon/lexicons.ts b/src/atproto/infrastructure/lexicon/lexicons.ts similarity index 100% rename from src/lexicon/lexicons.ts rename to src/atproto/infrastructure/lexicon/lexicons.ts diff --git a/src/lexicon/types/app/annos/annotation.ts b/src/atproto/infrastructure/lexicon/types/app/annos/annotation.ts similarity index 100% rename from src/lexicon/types/app/annos/annotation.ts rename to src/atproto/infrastructure/lexicon/types/app/annos/annotation.ts diff --git a/src/lexicon/types/app/annos/annotationField.ts b/src/atproto/infrastructure/lexicon/types/app/annos/annotationField.ts similarity index 100% rename from src/lexicon/types/app/annos/annotationField.ts rename to src/atproto/infrastructure/lexicon/types/app/annos/annotationField.ts diff --git a/src/lexicon/types/app/annos/annotationTemplate.ts b/src/atproto/infrastructure/lexicon/types/app/annos/annotationTemplate.ts similarity index 100% rename from src/lexicon/types/app/annos/annotationTemplate.ts rename to src/atproto/infrastructure/lexicon/types/app/annos/annotationTemplate.ts diff --git a/src/lexicon/types/app/annos/defs.ts b/src/atproto/infrastructure/lexicon/types/app/annos/defs.ts similarity index 100% rename from src/lexicon/types/app/annos/defs.ts rename to src/atproto/infrastructure/lexicon/types/app/annos/defs.ts diff --git a/src/lexicon/types/com/atproto/repo/strongRef.ts b/src/atproto/infrastructure/lexicon/types/com/atproto/repo/strongRef.ts similarity index 100% rename from src/lexicon/types/com/atproto/repo/strongRef.ts rename to src/atproto/infrastructure/lexicon/types/com/atproto/repo/strongRef.ts diff --git a/src/lexicon/util.ts b/src/atproto/infrastructure/lexicon/util.ts similarity index 100% rename from src/lexicon/util.ts rename to src/atproto/infrastructure/lexicon/util.ts diff --git a/src/annotations/application/dtos/AnnotationDTO.ts b/src/modules/annotations/application/dtos/AnnotationDTO.ts similarity index 100% rename from src/annotations/application/dtos/AnnotationDTO.ts rename to src/modules/annotations/application/dtos/AnnotationDTO.ts diff --git a/src/annotations/application/dtos/AnnotationFieldDTO.ts b/src/modules/annotations/application/dtos/AnnotationFieldDTO.ts similarity index 100% rename from src/annotations/application/dtos/AnnotationFieldDTO.ts rename to src/modules/annotations/application/dtos/AnnotationFieldDTO.ts diff --git a/src/annotations/application/dtos/AnnotationTemplateDTO.ts b/src/modules/annotations/application/dtos/AnnotationTemplateDTO.ts similarity index 100% rename from src/annotations/application/dtos/AnnotationTemplateDTO.ts rename to src/modules/annotations/application/dtos/AnnotationTemplateDTO.ts diff --git a/src/annotations/application/dtos/FillAnnotationTemplateDTO.ts b/src/modules/annotations/application/dtos/FillAnnotationTemplateDTO.ts similarity index 100% rename from src/annotations/application/dtos/FillAnnotationTemplateDTO.ts rename to src/modules/annotations/application/dtos/FillAnnotationTemplateDTO.ts diff --git a/src/annotations/application/dtos/index.ts b/src/modules/annotations/application/dtos/index.ts similarity index 100% rename from src/annotations/application/dtos/index.ts rename to src/modules/annotations/application/dtos/index.ts diff --git a/src/modules/annotations/application/repositories/IAnnotationFieldRepository.ts b/src/modules/annotations/application/repositories/IAnnotationFieldRepository.ts new file mode 100644 index 00000000..21c840a1 --- /dev/null +++ b/src/modules/annotations/application/repositories/IAnnotationFieldRepository.ts @@ -0,0 +1,12 @@ +import { AnnotationField } from "../../domain/aggregates/AnnotationField"; +import { TID } from "../../../../atproto/domain/value-objects/TID"; + +// Placeholder Interface for AnnotationField Repository +export interface IAnnotationFieldRepository { + findById(id: TID): Promise; + findByUri(uri: string): Promise; // Find by AT URI + findByName(name: string): Promise; // Example query + save(field: AnnotationField): Promise; + delete(id: TID): Promise; + // Add other query methods as needed +} diff --git a/src/modules/annotations/application/repositories/IAnnotationRepository.ts b/src/modules/annotations/application/repositories/IAnnotationRepository.ts new file mode 100644 index 00000000..e77ce305 --- /dev/null +++ b/src/modules/annotations/application/repositories/IAnnotationRepository.ts @@ -0,0 +1,13 @@ +import { Annotation } from "../../domain/aggregates/Annotation"; +import { TID } from "../../../../atproto/domain/value-objects/TID"; +import { URI } from "../../domain/value-objects/URI"; + +// Placeholder Interface for Annotation Repository +export interface IAnnotationRepository { + findById(id: TID): Promise; + findByUri(uri: string): Promise; // Find by AT URI + findByUrl(url: URI): Promise; // Find by annotated resource URL + save(annotation: Annotation): Promise; + delete(id: TID): Promise; + // Add other query methods as needed +} diff --git a/src/modules/annotations/application/repositories/IAnnotationTemplateRepository.ts b/src/modules/annotations/application/repositories/IAnnotationTemplateRepository.ts new file mode 100644 index 00000000..006a486a --- /dev/null +++ b/src/modules/annotations/application/repositories/IAnnotationTemplateRepository.ts @@ -0,0 +1,12 @@ +import { AnnotationTemplate } from "../../domain/aggregates/AnnotationTemplate"; +import { TID } from "../../../../atproto/domain/value-objects/TID"; + +// Placeholder Interface for AnnotationTemplate Repository +export interface IAnnotationTemplateRepository { + findById(id: TID): Promise; + findByUri(uri: string): Promise; // Find by AT URI + findByName(name: string): Promise; // Example query + save(template: AnnotationTemplate): Promise; + delete(id: TID): Promise; + // Add other query methods as needed +} diff --git a/src/annotations/application/repositories/index.ts b/src/modules/annotations/application/repositories/index.ts similarity index 100% rename from src/annotations/application/repositories/index.ts rename to src/modules/annotations/application/repositories/index.ts diff --git a/src/annotations/application/use-cases/AddAnnotationFieldToTemplateUseCase.ts b/src/modules/annotations/application/use-cases/AddAnnotationFieldToTemplateUseCase.ts similarity index 59% rename from src/annotations/application/use-cases/AddAnnotationFieldToTemplateUseCase.ts rename to src/modules/annotations/application/use-cases/AddAnnotationFieldToTemplateUseCase.ts index a93bfbb8..f7fc2da2 100644 --- a/src/annotations/application/use-cases/AddAnnotationFieldToTemplateUseCase.ts +++ b/src/modules/annotations/application/use-cases/AddAnnotationFieldToTemplateUseCase.ts @@ -1,18 +1,22 @@ -import { IAnnotationTemplateRepository } from '../repositories/IAnnotationTemplateRepository' -import { IAnnotationFieldRepository } from '../repositories/IAnnotationFieldRepository' -import { TID } from '../../../atproto/domain/value-objects/TID' -import { StrongRef } from '../../../atproto/domain/value-objects/StrongRef' -import { TemplateField } from '../../domain/value-objects/TemplateField' +import { IAnnotationTemplateRepository } from "../repositories/IAnnotationTemplateRepository"; +import { IAnnotationFieldRepository } from "../repositories/IAnnotationFieldRepository"; +import { TID } from "../../../../atproto/domain/value-objects/TID"; +import { StrongRef } from "../../../../atproto/domain/value-objects/StrongRef"; +import { TemplateField } from "../../domain/value-objects/TemplateField"; // Import DTOs // Placeholder for AddAnnotationFieldToTemplateUseCase export class AddAnnotationFieldToTemplateUseCase { constructor( private templateRepo: IAnnotationTemplateRepository, - private fieldRepo: IAnnotationFieldRepository, // To validate field exists + private fieldRepo: IAnnotationFieldRepository // To validate field exists ) {} - async execute(input: { templateId: TID; fieldRef: StrongRef; required?: boolean }): Promise { + async execute(input: { + templateId: TID; + fieldRef: StrongRef; + required?: boolean; + }): Promise { // 1. Validate input // 2. Fetch the AnnotationTemplate using templateRepo.findById(templateId) // 3. Fetch the AnnotationField using fieldRepo.findByUri(fieldRef.uri) to ensure it exists @@ -20,7 +24,10 @@ export class AddAnnotationFieldToTemplateUseCase { // 5. Call template.addField(templateField) domain method // 6. Persist the updated template using templateRepo.save(template) - console.log('Executing AddAnnotationFieldToTemplateUseCase with input:', input) + console.log( + "Executing AddAnnotationFieldToTemplateUseCase with input:", + input + ); // Placeholder implementation // const template = await this.templateRepo.findById(input.templateId); // if (!template) throw new Error('Template not found'); @@ -29,6 +36,6 @@ export class AddAnnotationFieldToTemplateUseCase { // const templateField = new TemplateField(input.fieldRef, input.required); // template.addField(templateField); // Assuming this method handles duplicates etc. // await this.templateRepo.save(template); - throw new Error('Not implemented') + throw new Error("Not implemented"); } } diff --git a/src/annotations/application/use-cases/CreateAnnotationFieldUseCase.ts b/src/modules/annotations/application/use-cases/CreateAnnotationFieldUseCase.ts similarity index 100% rename from src/annotations/application/use-cases/CreateAnnotationFieldUseCase.ts rename to src/modules/annotations/application/use-cases/CreateAnnotationFieldUseCase.ts diff --git a/src/annotations/application/use-cases/CreateAnnotationTemplateUseCase.ts b/src/modules/annotations/application/use-cases/CreateAnnotationTemplateUseCase.ts similarity index 60% rename from src/annotations/application/use-cases/CreateAnnotationTemplateUseCase.ts rename to src/modules/annotations/application/use-cases/CreateAnnotationTemplateUseCase.ts index 72ce55c1..a8c2e7b2 100644 --- a/src/annotations/application/use-cases/CreateAnnotationTemplateUseCase.ts +++ b/src/modules/annotations/application/use-cases/CreateAnnotationTemplateUseCase.ts @@ -1,18 +1,20 @@ -import { AnnotationTemplate } from '../../domain/aggregates/AnnotationTemplate' -import { IAnnotationTemplateRepository } from '../repositories/IAnnotationTemplateRepository' -import { IAnnotationFieldRepository } from '../repositories/IAnnotationFieldRepository' // To validate field refs -import { TemplateField } from '../../domain/value-objects/TemplateField' -import { StrongRef } from '../../../atproto/domain/value-objects/StrongRef' +import { AnnotationTemplate } from "../../domain/aggregates/AnnotationTemplate"; +import { IAnnotationTemplateRepository } from "../repositories/IAnnotationTemplateRepository"; +import { IAnnotationFieldRepository } from "../repositories/IAnnotationFieldRepository"; // To validate field refs +import { TemplateField } from "../../domain/value-objects/TemplateField"; +import { StrongRef } from "../../../../atproto/domain/value-objects/StrongRef"; // Import DTOs // Placeholder for CreateAnnotationTemplateUseCase export class CreateAnnotationTemplateUseCase { constructor( private templateRepo: IAnnotationTemplateRepository, - private fieldRepo: IAnnotationFieldRepository, // To validate field refs exist + private fieldRepo: IAnnotationFieldRepository // To validate field refs exist ) {} - async execute(input: /* Input DTO */ any): Promise { + async execute( + input: /* Input DTO */ any + ): Promise { // 1. Validate input DTO // 2. Validate that all referenced annotation fields exist using fieldRepo // 3. Create TemplateField value objects from input.annotationFields @@ -20,7 +22,7 @@ export class CreateAnnotationTemplateUseCase { // 5. Persist using templateRepo.save(template) // 6. Return result - console.log('Executing CreateAnnotationTemplateUseCase with input:', input) + console.log("Executing CreateAnnotationTemplateUseCase with input:", input); // Placeholder implementation // const fieldRefs = input.annotationFields.map(f => new StrongRef(f.ref.cid, f.ref.uri)); // // Optional: Validate fields exist @@ -29,6 +31,6 @@ export class CreateAnnotationTemplateUseCase { // const template = AnnotationTemplate.create({ name: input.name, ..., annotationFields: templateFields }); // await this.templateRepo.save(template); // return template.id; - throw new Error('Not implemented') + throw new Error("Not implemented"); } } diff --git a/src/annotations/application/use-cases/CreateAnnotationUseCase.ts b/src/modules/annotations/application/use-cases/CreateAnnotationUseCase.ts similarity index 100% rename from src/annotations/application/use-cases/CreateAnnotationUseCase.ts rename to src/modules/annotations/application/use-cases/CreateAnnotationUseCase.ts diff --git a/src/annotations/application/use-cases/FillAnnotationTemplateUseCase.ts b/src/modules/annotations/application/use-cases/FillAnnotationTemplateUseCase.ts similarity index 98% rename from src/annotations/application/use-cases/FillAnnotationTemplateUseCase.ts rename to src/modules/annotations/application/use-cases/FillAnnotationTemplateUseCase.ts index 42efffd4..1582f19a 100644 --- a/src/annotations/application/use-cases/FillAnnotationTemplateUseCase.ts +++ b/src/modules/annotations/application/use-cases/FillAnnotationTemplateUseCase.ts @@ -8,7 +8,7 @@ import { import { Annotation } from "../../domain/aggregates/Annotation"; import { AnnotationField } from "../../domain/aggregates/AnnotationField"; import { URI } from "../../domain/value-objects/URI"; -import { StrongRef } from "../../../atproto/domain/value-objects/StrongRef"; +import { StrongRef } from "../../../../atproto/domain/value-objects/StrongRef"; import { Identifier } from "../../domain/value-objects/Identifier"; import { AnnotationValue, diff --git a/src/annotations/application/use-cases/GetAnnotationFieldUseCase.ts b/src/modules/annotations/application/use-cases/GetAnnotationFieldUseCase.ts similarity index 54% rename from src/annotations/application/use-cases/GetAnnotationFieldUseCase.ts rename to src/modules/annotations/application/use-cases/GetAnnotationFieldUseCase.ts index e888dc4b..b0a5ff33 100644 --- a/src/annotations/application/use-cases/GetAnnotationFieldUseCase.ts +++ b/src/modules/annotations/application/use-cases/GetAnnotationFieldUseCase.ts @@ -1,26 +1,26 @@ -import { IAnnotationFieldRepository } from '../repositories/IAnnotationFieldRepository' -import { TID } from '../../../atproto/domain/value-objects/TID' -import { AnnotationFieldOutputDTO } from '../dtos/AnnotationFieldDTO' -import { AnnotationFieldMapper } from '../../infrastructure/persistence/drizzle/mappers/AnnotationFieldMapper' // Adjust path if needed -import { AnnotationField } from '../../domain/aggregates/AnnotationField' // Import Aggregate +import { IAnnotationFieldRepository } from "../repositories/IAnnotationFieldRepository"; +import { TID } from "../../../../atproto/domain/value-objects/TID"; +import { AnnotationFieldOutputDTO } from "../dtos/AnnotationFieldDTO"; +import { AnnotationFieldMapper } from "../../infrastructure/persistence/drizzle/mappers/AnnotationFieldMapper"; // Adjust path if needed +import { AnnotationField } from "../../domain/aggregates/AnnotationField"; // Import Aggregate export class GetAnnotationFieldUseCase { constructor(private fieldRepo: IAnnotationFieldRepository) {} async execute(id: TID): Promise { // 1. Fetch field using fieldRepo.findById(id) - const field: AnnotationField | null = await this.fieldRepo.findById(id) + const field: AnnotationField | null = await this.fieldRepo.findById(id); // 2. Handle not found case if (!field) { // Decide on behavior: return null, throw specific error, etc. // Returning null aligns with repository behavior often. - return null + return null; } // 3. Map to DTO // Assuming a mapper exists. If not, manual mapping is needed. // TODO: Implement or verify AnnotationFieldMapper exists and works - return AnnotationFieldMapper.toDTO(field) + return AnnotationFieldMapper.toDTO(field); } } diff --git a/src/annotations/application/use-cases/GetAnnotationTemplateUseCase.ts b/src/modules/annotations/application/use-cases/GetAnnotationTemplateUseCase.ts similarity index 65% rename from src/annotations/application/use-cases/GetAnnotationTemplateUseCase.ts rename to src/modules/annotations/application/use-cases/GetAnnotationTemplateUseCase.ts index 50a0efcb..f02aeb2a 100644 --- a/src/annotations/application/use-cases/GetAnnotationTemplateUseCase.ts +++ b/src/modules/annotations/application/use-cases/GetAnnotationTemplateUseCase.ts @@ -1,5 +1,5 @@ -import { IAnnotationTemplateRepository } from '../repositories/IAnnotationTemplateRepository' -import { TID } from '../../../atproto/domain/value-objects/TID' +import { IAnnotationTemplateRepository } from "../repositories/IAnnotationTemplateRepository"; +import { TID } from "../../../../atproto/domain/value-objects/TID"; // Import DTOs // Placeholder for GetAnnotationTemplateUseCase @@ -11,11 +11,14 @@ export class GetAnnotationTemplateUseCase { // 2. Map to DTO if needed // 3. Return result - console.log('Executing GetAnnotationTemplateUseCase with id:', id.toString()) + console.log( + "Executing GetAnnotationTemplateUseCase with id:", + id.toString() + ); // Placeholder implementation // const template = await this.templateRepo.findById(id); // if (!template) throw new Error('AnnotationTemplate not found'); // return template; // Or map to DTO - throw new Error('Not implemented') + throw new Error("Not implemented"); } } diff --git a/src/annotations/application/use-cases/GetAnnotationUseCase.ts b/src/modules/annotations/application/use-cases/GetAnnotationUseCase.ts similarity index 69% rename from src/annotations/application/use-cases/GetAnnotationUseCase.ts rename to src/modules/annotations/application/use-cases/GetAnnotationUseCase.ts index d5d59736..ee2ac9e1 100644 --- a/src/annotations/application/use-cases/GetAnnotationUseCase.ts +++ b/src/modules/annotations/application/use-cases/GetAnnotationUseCase.ts @@ -1,5 +1,5 @@ -import { IAnnotationRepository } from '../repositories/IAnnotationRepository' -import { TID } from '../../../atproto/domain/value-objects/TID' +import { IAnnotationRepository } from "../repositories/IAnnotationRepository"; +import { TID } from "../../../../atproto/domain/value-objects/TID"; // Import DTOs for output if needed // Placeholder for GetAnnotationUseCase @@ -11,11 +11,11 @@ export class GetAnnotationUseCase { // 2. Map domain object to Output DTO if necessary // 3. Return result - console.log('Executing GetAnnotationUseCase with id:', id.toString()) + console.log("Executing GetAnnotationUseCase with id:", id.toString()); // Placeholder implementation // const annotation = await this.annotationRepo.findById(id); // if (!annotation) throw new Error('Annotation not found'); // return annotation; // Or map to DTO - throw new Error('Not implemented') + throw new Error("Not implemented"); } } diff --git a/src/annotations/application/use-cases/ListAnnotationsForResourceUseCase.ts b/src/modules/annotations/application/use-cases/ListAnnotationsForResourceUseCase.ts similarity index 100% rename from src/annotations/application/use-cases/ListAnnotationsForResourceUseCase.ts rename to src/modules/annotations/application/use-cases/ListAnnotationsForResourceUseCase.ts diff --git a/src/annotations/application/use-cases/index.ts b/src/modules/annotations/application/use-cases/index.ts similarity index 100% rename from src/annotations/application/use-cases/index.ts rename to src/modules/annotations/application/use-cases/index.ts diff --git a/src/modules/annotations/domain/aggregates/Annotation.ts b/src/modules/annotations/domain/aggregates/Annotation.ts new file mode 100644 index 00000000..86be185a --- /dev/null +++ b/src/modules/annotations/domain/aggregates/Annotation.ts @@ -0,0 +1,72 @@ +import { StrongRef } from "../../../../atproto/domain/value-objects/StrongRef"; +import { Identifier } from "../value-objects/Identifier"; +import { AnnotationValue } from "../value-objects/AnnotationValue"; +import { URI } from "../value-objects/URI"; +import { TID } from "../../../../atproto/domain/value-objects/TID"; + +// Properties required to construct an Annotation +export interface AnnotationProps { + url: URI; + annotationFieldId: AnnotationFieldId; + value: AnnotationValue; + annotationTemplateIds?: AnnotationTemplateId[]; + note?: AnnotationNote; + createdAt: Date; +} + +// Properties required to create a new Annotation (ID and createdAt are generated) +export type AnnotationCreateProps = Omit< + AnnotationProps, + "id" | "createdAt" +> & { + id?: TID; // Allow providing an ID optionally +}; + +// Placeholder for Annotation Aggregate Root +export class Annotation { + readonly id: TID; + readonly url: URI; + readonly fieldRef: StrongRef; // Reference to AnnotationField + readonly value: AnnotationValue; + readonly additionalIdentifiers?: Identifier[]; + readonly templateRefs?: StrongRef[]; // Reference to AnnotationTemplate(s) + readonly note?: string; + readonly createdAt: Date; + + // Private constructor to enforce creation via factory or repository + private constructor(props: AnnotationProps) { + this.id = props.id; + this.url = props.url; + this.fieldRef = props.fieldRef; + this.value = props.value; + this.additionalIdentifiers = props.additionalIdentifiers; + this.templateRefs = props.templateRefs; + this.note = props.note; + this.createdAt = props.createdAt; + + // TODO: Add validation logic here or in a factory + // - Ensure value type matches field definition (requires fetching field) + } + + // Factory method for creating new Annotation instances + public static create(props: AnnotationCreateProps): Annotation { + const id = props.id ?? TID.create(); // Generate TID if not provided + const createdAt = new Date(); // Set creation timestamp + + // TODO: Add complex validation logic, potentially involving fetching the AnnotationField + // to ensure the value type is compatible with the field definition. + // This validation might be better suited for an Application Service/UseCase + // that has access to repositories. + + // Construct the full props object for the private constructor + const constructorProps: AnnotationProps = { + ...props, // Spread properties from create input (url, fieldRef, value, etc.) + id, // Use the generated or provided ID + createdAt, // Use the generated timestamp + }; + + return new Annotation(constructorProps); + } + + // Methods for business logic related to Annotation +} diff --git a/src/annotations/domain/aggregates/AnnotationField.ts b/src/modules/annotations/domain/aggregates/AnnotationField.ts similarity index 51% rename from src/annotations/domain/aggregates/AnnotationField.ts rename to src/modules/annotations/domain/aggregates/AnnotationField.ts index ca1315e4..8ab00c2e 100644 --- a/src/annotations/domain/aggregates/AnnotationField.ts +++ b/src/modules/annotations/domain/aggregates/AnnotationField.ts @@ -1,48 +1,48 @@ -import { FieldDefinition } from '../value-objects/FieldDefinition' -import { TID } from '../../../atproto/domain/value-objects/TID' +import { FieldDefinition } from "../value-objects/FieldDefinition"; +import { TID } from "../../../../atproto/domain/value-objects/TID"; // Properties required to construct an AnnotationField export interface AnnotationFieldProps { - id: TID - name: string - description: string - definition: FieldDefinition - createdAt: Date + id: TID; + name: string; + description: string; + definition: FieldDefinition; + createdAt: Date; } // Properties required to create a new AnnotationField export type AnnotationFieldCreateProps = Omit< AnnotationFieldProps, - 'id' | 'createdAt' + "id" | "createdAt" > & { - id?: TID // Allow providing an ID optionally -} + id?: TID; // Allow providing an ID optionally +}; // Placeholder for AnnotationField Aggregate Root export class AnnotationField { - readonly id: TID - readonly name: string - readonly description: string - readonly definition: FieldDefinition - readonly createdAt: Date + readonly id: TID; + readonly name: string; + readonly description: string; + readonly definition: FieldDefinition; + readonly createdAt: Date; private constructor(props: AnnotationFieldProps) { - this.id = props.id - this.name = props.name - this.description = props.description - this.definition = props.definition - this.createdAt = props.createdAt + this.id = props.id; + this.name = props.name; + this.description = props.description; + this.definition = props.definition; + this.createdAt = props.createdAt; // TODO: Add more validation logic here if needed } public static create(props: AnnotationFieldCreateProps): AnnotationField { - const id = props.id ?? TID.create() - const createdAt = new Date() + const id = props.id ?? TID.create(); + const createdAt = new Date(); // TODO: Add validation for definition, name, description if (!props.name || props.name.trim().length === 0) { - throw new Error('AnnotationField name cannot be empty.') + throw new Error("AnnotationField name cannot be empty."); } // Add more validation as needed (e.g., description length, definition checks) @@ -50,9 +50,9 @@ export class AnnotationField { ...props, id, createdAt, - } + }; - return new AnnotationField(constructorProps) + return new AnnotationField(constructorProps); } // Methods for business logic related to AnnotationField diff --git a/src/modules/annotations/domain/aggregates/AnnotationTemplate.ts b/src/modules/annotations/domain/aggregates/AnnotationTemplate.ts new file mode 100644 index 00000000..af5b8f83 --- /dev/null +++ b/src/modules/annotations/domain/aggregates/AnnotationTemplate.ts @@ -0,0 +1,79 @@ +import { TemplateField } from "../value-objects/TemplateField"; +import { TID } from "../../../../atproto/domain/value-objects/TID"; + +// Properties required to construct an AnnotationTemplate +export interface AnnotationTemplateProps { + id: TID; + name: string; + description: string; + annotationFields: TemplateField[]; + createdAt: Date; +} + +// Properties required to create a new AnnotationTemplate +export type AnnotationTemplateCreateProps = Omit< + AnnotationTemplateProps, + "id" | "createdAt" +> & { + id?: TID; // Allow providing an ID optionally +}; + +// Placeholder for AnnotationTemplate Aggregate Root +export class AnnotationTemplate { + readonly id: TID; + readonly name: string; + readonly description: string; + readonly annotationFields: TemplateField[]; + readonly createdAt: Date; + + private constructor(props: AnnotationTemplateProps) { + this.id = props.id; + this.name = props.name; + this.description = props.description; + this.annotationFields = props.annotationFields; + this.createdAt = props.createdAt; + + // TODO: Add more validation logic here if needed + } + + public static create( + props: AnnotationTemplateCreateProps + ): AnnotationTemplate { + const id = props.id ?? TID.create(); + const createdAt = new Date(); + + // TODO: Add validation + if (!props.name || props.name.trim().length === 0) { + throw new Error("AnnotationTemplate name cannot be empty."); + } + if (!props.annotationFields || props.annotationFields.length === 0) { + throw new Error("AnnotationTemplate must include at least one field."); + } + // Add more validation as needed (e.g., description length) + + const constructorProps: AnnotationTemplateProps = { + ...props, + id, + createdAt, + }; + + return new AnnotationTemplate(constructorProps); + } + + // Method to add a field (example of behavior) + public addField(field: TemplateField): void { + // TODO: Add logic to prevent duplicates, etc. + this.annotationFields.push(field); + } + + // Method to remove a field + public removeField(fieldRefUri: string): void { + // TODO: Implement removal logic + const index = this.annotationFields.findIndex( + (f) => f.fieldRef.uri === fieldRefUri + ); + if (index > -1) { + this.annotationFields.splice(index, 1); + } + } +} diff --git a/src/annotations/domain/aggregates/index.ts b/src/modules/annotations/domain/aggregates/index.ts similarity index 100% rename from src/annotations/domain/aggregates/index.ts rename to src/modules/annotations/domain/aggregates/index.ts diff --git a/src/annotations/domain/value-objects/AnnotationValue.ts b/src/modules/annotations/domain/value-objects/AnnotationValue.ts similarity index 100% rename from src/annotations/domain/value-objects/AnnotationValue.ts rename to src/modules/annotations/domain/value-objects/AnnotationValue.ts diff --git a/src/annotations/domain/value-objects/FieldDefinition.ts b/src/modules/annotations/domain/value-objects/FieldDefinition.ts similarity index 100% rename from src/annotations/domain/value-objects/FieldDefinition.ts rename to src/modules/annotations/domain/value-objects/FieldDefinition.ts diff --git a/src/annotations/domain/value-objects/Identifier.ts b/src/modules/annotations/domain/value-objects/Identifier.ts similarity index 100% rename from src/annotations/domain/value-objects/Identifier.ts rename to src/modules/annotations/domain/value-objects/Identifier.ts diff --git a/src/modules/annotations/domain/value-objects/TemplateField.ts b/src/modules/annotations/domain/value-objects/TemplateField.ts new file mode 100644 index 00000000..add49107 --- /dev/null +++ b/src/modules/annotations/domain/value-objects/TemplateField.ts @@ -0,0 +1,14 @@ +import { StrongRef } from "../../../../atproto/domain/value-objects/StrongRef"; + +// Placeholder for TemplateField Value Object +export class TemplateField { + readonly $type = "app.annos.annotationTemplate#annotationFieldRef"; // Match lexicon def + readonly fieldRef: StrongRef; // Reference to AnnotationField + readonly required: boolean; + + constructor(fieldRef: StrongRef, required: boolean = false) { + this.fieldRef = fieldRef; + this.required = required; + // TODO: Add validation if needed + } +} diff --git a/src/annotations/domain/value-objects/URI.ts b/src/modules/annotations/domain/value-objects/URI.ts similarity index 100% rename from src/annotations/domain/value-objects/URI.ts rename to src/modules/annotations/domain/value-objects/URI.ts diff --git a/src/annotations/domain/value-objects/index.ts b/src/modules/annotations/domain/value-objects/index.ts similarity index 100% rename from src/annotations/domain/value-objects/index.ts rename to src/modules/annotations/domain/value-objects/index.ts diff --git a/src/annotations/infrastructure/persistence/drizzle/client.ts b/src/modules/annotations/infrastructure/persistence/drizzle/client.ts similarity index 100% rename from src/annotations/infrastructure/persistence/drizzle/client.ts rename to src/modules/annotations/infrastructure/persistence/drizzle/client.ts diff --git a/src/annotations/infrastructure/persistence/drizzle/schema.ts b/src/modules/annotations/infrastructure/persistence/drizzle/schema.ts similarity index 100% rename from src/annotations/infrastructure/persistence/drizzle/schema.ts rename to src/modules/annotations/infrastructure/persistence/drizzle/schema.ts diff --git a/src/annotations/infrastructure/persistence/mappers/AnnotationFieldMapper.ts b/src/modules/annotations/infrastructure/persistence/mappers/AnnotationFieldMapper.ts similarity index 73% rename from src/annotations/infrastructure/persistence/mappers/AnnotationFieldMapper.ts rename to src/modules/annotations/infrastructure/persistence/mappers/AnnotationFieldMapper.ts index 87db3736..3328679c 100644 --- a/src/annotations/infrastructure/persistence/mappers/AnnotationFieldMapper.ts +++ b/src/modules/annotations/infrastructure/persistence/mappers/AnnotationFieldMapper.ts @@ -1,9 +1,9 @@ import { AnnotationField, AnnotationFieldProps, -} from '../../../domain/aggregates/AnnotationField' -import { AnnotationFieldOutputDTO } from '../../../application/dtos/AnnotationFieldDTO' -import { TID } from '../../../../atproto/domain/value-objects/TID' +} from "../../../domain/aggregates/AnnotationField"; +import { AnnotationFieldOutputDTO } from "../../../application/dtos/AnnotationFieldDTO"; +import { TID } from "../../../../../atproto/domain/value-objects/TID"; import { FieldDefinition, DyadFieldDef, @@ -11,7 +11,7 @@ import { RatingFieldDef, SingleSelectFieldDef, TriadFieldDef, -} from '../../../domain/value-objects/FieldDefinition' +} from "../../../domain/value-objects/FieldDefinition"; // Placeholder for mapping between AnnotationField domain object and persistence layer structure // Import DB schema types if needed @@ -25,14 +25,17 @@ export class AnnotationFieldMapper { public static toDomain(raw: any): AnnotationField { // TODO: Implement mapping from raw DB result to AnnotationField aggregate root - console.log('Mapping DB result to AnnotationField domain object:', raw) - throw new Error('toDomain mapping not implemented') + console.log("Mapping DB result to AnnotationField domain object:", raw); + throw new Error("toDomain mapping not implemented"); } public static toPersistence(field: AnnotationField): any { // TODO: Implement mapping from AnnotationField aggregate root to DB structure - console.log('Mapping AnnotationField domain object to persistence structure:', field) - throw new Error('toPersistence mapping not implemented') + console.log( + "Mapping AnnotationField domain object to persistence structure:", + field + ); + throw new Error("toPersistence mapping not implemented"); // Example: // return { // uri: field.uri.toString(), // Assuming URI is known/generated @@ -52,6 +55,6 @@ export class AnnotationFieldMapper { // Assuming definition value object is structured correctly for direct use in DTO: definition: field.definition, createdAt: field.createdAt.toISOString(), - } + }; } } diff --git a/src/annotations/infrastructure/persistence/mappers/AnnotationMapper.ts b/src/modules/annotations/infrastructure/persistence/mappers/AnnotationMapper.ts similarity index 90% rename from src/annotations/infrastructure/persistence/mappers/AnnotationMapper.ts rename to src/modules/annotations/infrastructure/persistence/mappers/AnnotationMapper.ts index a7c6acf5..ce6c79d5 100644 --- a/src/annotations/infrastructure/persistence/mappers/AnnotationMapper.ts +++ b/src/modules/annotations/infrastructure/persistence/mappers/AnnotationMapper.ts @@ -1,9 +1,12 @@ -import { Annotation, AnnotationProps } from '../../../domain/aggregates/Annotation' -import { AnnotationOutputDTO } from '../../../application/dtos/AnnotationDTO' -import { TID } from '../../../../atproto/domain/value-objects/TID' -import { URI } from '../../../domain/value-objects/URI' -import { StrongRef } from '../../../../atproto/domain/value-objects/StrongRef' -import { Identifier } from '../../../domain/value-objects/Identifier' +import { + Annotation, + AnnotationProps, +} from "../../../domain/aggregates/Annotation"; +import { AnnotationOutputDTO } from "../../../application/dtos/AnnotationDTO"; +import { TID } from "../../../../../atproto/domain/value-objects/TID"; +import { URI } from "../../../domain/value-objects/URI"; +import { StrongRef } from "../../../../../atproto/domain/value-objects/StrongRef"; +import { Identifier } from "../../../domain/value-objects/Identifier"; import { AnnotationValue, DyadValue, @@ -11,7 +14,7 @@ import { RatingValue, SingleSelectValue, TriadValue, -} from '../../../domain/value-objects/AnnotationValue' +} from "../../../domain/value-objects/AnnotationValue"; // Placeholder for mapping between Annotation domain object and persistence layer structure // Import DB schema types if needed @@ -103,7 +106,7 @@ export class AnnotationMapper { })), note: annotation.note, createdAt: annotation.createdAt.toISOString(), - } + }; } } diff --git a/src/annotations/infrastructure/persistence/mappers/AnnotationTemplateMapper.ts b/src/modules/annotations/infrastructure/persistence/mappers/AnnotationTemplateMapper.ts similarity index 100% rename from src/annotations/infrastructure/persistence/mappers/AnnotationTemplateMapper.ts rename to src/modules/annotations/infrastructure/persistence/mappers/AnnotationTemplateMapper.ts diff --git a/src/annotations/infrastructure/persistence/mappers/index.ts b/src/modules/annotations/infrastructure/persistence/mappers/index.ts similarity index 100% rename from src/annotations/infrastructure/persistence/mappers/index.ts rename to src/modules/annotations/infrastructure/persistence/mappers/index.ts diff --git a/src/annotations/infrastructure/persistence/repositories/AnnotationFieldRepository.ts b/src/modules/annotations/infrastructure/persistence/repositories/AnnotationFieldRepository.ts similarity index 58% rename from src/annotations/infrastructure/persistence/repositories/AnnotationFieldRepository.ts rename to src/modules/annotations/infrastructure/persistence/repositories/AnnotationFieldRepository.ts index 7ebc77ff..4b7b7e71 100644 --- a/src/annotations/infrastructure/persistence/repositories/AnnotationFieldRepository.ts +++ b/src/modules/annotations/infrastructure/persistence/repositories/AnnotationFieldRepository.ts @@ -1,9 +1,9 @@ -import { eq } from 'drizzle-orm' -import { db } from '../drizzle/client' -import * as schema from '../drizzle/schema' -import { IAnnotationFieldRepository } from '../../../application/repositories/IAnnotationFieldRepository' -import { AnnotationField } from '../../../domain/aggregates/AnnotationField' -import { TID } from '../../../../atproto/domain/value-objects/TID' +import { eq } from "drizzle-orm"; +import { db } from "../drizzle/client"; +import * as schema from "../drizzle/schema"; +import { IAnnotationFieldRepository } from "../../../application/repositories/IAnnotationFieldRepository"; +import { AnnotationField } from "../../../domain/aggregates/AnnotationField"; +import { TID } from "../../../../../atproto/domain/value-objects/TID"; // Import mappers if using them // Placeholder Drizzle implementation of IAnnotationFieldRepository @@ -11,28 +11,28 @@ export class AnnotationFieldRepository implements IAnnotationFieldRepository { async findById(id: TID): Promise { const result = await db.query.annotationFields.findFirst({ where: eq(schema.annotationFields.tid, id.toString()), - }) - if (!result) return null + }); + if (!result) return null; // TODO: Map to domain - throw new Error('Mapping from DB to Domain not implemented') + throw new Error("Mapping from DB to Domain not implemented"); } async findByUri(uri: string): Promise { - const result = await db.query.annotationFields.findFirst({ + const result = await db.query.annotationFields.findFirst({ where: eq(schema.annotationFields.uri, uri), - }) - if (!result) return null + }); + if (!result) return null; // TODO: Map to domain - throw new Error('Mapping from DB to Domain not implemented') + throw new Error("Mapping from DB to Domain not implemented"); } async findByName(name: string): Promise { const result = await db.query.annotationFields.findFirst({ where: eq(schema.annotationFields.name, name), - }) - if (!result) return null + }); + if (!result) return null; // TODO: Map to domain - throw new Error('Mapping from DB to Domain not implemented') + throw new Error("Mapping from DB to Domain not implemented"); } async save(field: AnnotationField): Promise { @@ -42,15 +42,15 @@ export class AnnotationFieldRepository implements IAnnotationFieldRepository { // .values(dbField) // .onConflict(...) // .doUpdate(...); - console.log('Saving annotation field:', field.id.toString()) - throw new Error('Save not implemented') + console.log("Saving annotation field:", field.id.toString()); + throw new Error("Save not implemented"); } async delete(id: TID): Promise { // TODO: Implement deletion, consider dependencies (templates, annotations) // Might need to check if field is in use before deleting or handle cascading deletes. // await db.delete(schema.annotationFields).where(eq(schema.annotationFields.tid, id.toString())); - console.log('Deleting annotation field:', id.toString()) - throw new Error('Delete not implemented') + console.log("Deleting annotation field:", id.toString()); + throw new Error("Delete not implemented"); } } diff --git a/src/annotations/infrastructure/persistence/repositories/AnnotationRepository.ts b/src/modules/annotations/infrastructure/persistence/repositories/AnnotationRepository.ts similarity index 73% rename from src/annotations/infrastructure/persistence/repositories/AnnotationRepository.ts rename to src/modules/annotations/infrastructure/persistence/repositories/AnnotationRepository.ts index 2487003e..6273fcf3 100644 --- a/src/annotations/infrastructure/persistence/repositories/AnnotationRepository.ts +++ b/src/modules/annotations/infrastructure/persistence/repositories/AnnotationRepository.ts @@ -1,10 +1,10 @@ -import { eq, and } from 'drizzle-orm' -import { db } from '../drizzle/client' -import * as schema from '../drizzle/schema' -import { IAnnotationRepository } from '../../../application/repositories/IAnnotationRepository' -import { Annotation } from '../../../domain/aggregates/Annotation' -import { TID } from '../../../../atproto/domain/value-objects/TID' -import { URI } from '../../../domain/value-objects/URI' +import { eq, and } from "drizzle-orm"; +import { db } from "../drizzle/client"; +import * as schema from "../drizzle/schema"; +import { IAnnotationRepository } from "../../../application/repositories/IAnnotationRepository"; +import { Annotation } from "../../../domain/aggregates/Annotation"; +import { TID } from "../../../../../atproto/domain/value-objects/TID"; +import { URI } from "../../../domain/value-objects/URI"; // Import mappers if using them // import { AnnotationMapper } from '../mappers/AnnotationMapper' @@ -16,32 +16,32 @@ export class AnnotationRepository implements IAnnotationRepository { where: eq(schema.annotations.tid, id.toString()), // Eager load relations if needed using 'with' // with: { field: true, fromTemplates: true, additionalIdentifiers: true } - }) - if (!result) return null + }); + if (!result) return null; // TODO: Map database result to domain object (using a Mapper class is recommended) // return AnnotationMapper.toDomain(result); - throw new Error('Mapping from DB to Domain not implemented') + throw new Error("Mapping from DB to Domain not implemented"); } async findByUri(uri: string): Promise { const result = await db.query.annotations.findFirst({ where: eq(schema.annotations.uri, uri), // with: { ... } - }) - if (!result) return null + }); + if (!result) return null; // TODO: Map to domain // return AnnotationMapper.toDomain(result); - throw new Error('Mapping from DB to Domain not implemented') + throw new Error("Mapping from DB to Domain not implemented"); } async findByUrl(url: URI): Promise { const results = await db.query.annotations.findMany({ where: eq(schema.annotations.url, url.toString()), // with: { ... } - }) + }); // TODO: Map results to domain objects // return results.map(AnnotationMapper.toDomain); - throw new Error('Mapping from DB to Domain not implemented') + throw new Error("Mapping from DB to Domain not implemented"); } async save(annotation: Annotation): Promise { @@ -58,15 +58,15 @@ export class AnnotationRepository implements IAnnotationRepository { // TODO: Handle saving related data (identifiers, template refs) in separate tables // This often involves deleting existing related records and inserting new ones within a transaction. - console.log('Saving annotation:', annotation.id.toString()) - throw new Error('Save not implemented') + console.log("Saving annotation:", annotation.id.toString()); + throw new Error("Save not implemented"); } async delete(id: TID): Promise { // Use URI derived from TID for deletion? Or just TID? Depends on schema PK. // Assuming deletion by TID for now. Need URI to delete relations. - const annotation = await this.findById(id) - if (!annotation) return // Or throw error? + const annotation = await this.findById(id); + if (!annotation) return; // Or throw error? // TODO: Wrap in transaction // await db.transaction(async (tx) => { @@ -77,7 +77,7 @@ export class AnnotationRepository implements IAnnotationRepository { // await tx.delete(schema.annotations).where(eq(schema.annotations.tid, id.toString())); // }); - console.log('Deleting annotation:', id.toString()) - throw new Error('Delete not implemented') + console.log("Deleting annotation:", id.toString()); + throw new Error("Delete not implemented"); } } diff --git a/src/annotations/infrastructure/persistence/repositories/AnnotationTemplateRepository.ts b/src/modules/annotations/infrastructure/persistence/repositories/AnnotationTemplateRepository.ts similarity index 66% rename from src/annotations/infrastructure/persistence/repositories/AnnotationTemplateRepository.ts rename to src/modules/annotations/infrastructure/persistence/repositories/AnnotationTemplateRepository.ts index bb015ac9..5c83f8e7 100644 --- a/src/annotations/infrastructure/persistence/repositories/AnnotationTemplateRepository.ts +++ b/src/modules/annotations/infrastructure/persistence/repositories/AnnotationTemplateRepository.ts @@ -1,41 +1,43 @@ -import { eq } from 'drizzle-orm' -import { db } from '../drizzle/client' -import * as schema from '../drizzle/schema' -import { IAnnotationTemplateRepository } from '../../../application/repositories/IAnnotationTemplateRepository' -import { AnnotationTemplate } from '../../../domain/aggregates/AnnotationTemplate' -import { TID } from '../../../../atproto/domain/value-objects/TID' +import { eq } from "drizzle-orm"; +import { db } from "../drizzle/client"; +import * as schema from "../drizzle/schema"; +import { IAnnotationTemplateRepository } from "../../../application/repositories/IAnnotationTemplateRepository"; +import { AnnotationTemplate } from "../../../domain/aggregates/AnnotationTemplate"; +import { TID } from "../../../../../atproto/domain/value-objects/TID"; // Import mappers if using them // Placeholder Drizzle implementation of IAnnotationTemplateRepository -export class AnnotationTemplateRepository implements IAnnotationTemplateRepository { +export class AnnotationTemplateRepository + implements IAnnotationTemplateRepository +{ async findById(id: TID): Promise { const result = await db.query.annotationTemplates.findFirst({ where: eq(schema.annotationTemplates.tid, id.toString()), // with: { templateFields: { with: { field: true } } } // Eager load fields - }) - if (!result) return null + }); + if (!result) return null; // TODO: Map to domain - throw new Error('Mapping from DB to Domain not implemented') + throw new Error("Mapping from DB to Domain not implemented"); } async findByUri(uri: string): Promise { - const result = await db.query.annotationTemplates.findFirst({ + const result = await db.query.annotationTemplates.findFirst({ where: eq(schema.annotationTemplates.uri, uri), // with: { ... } - }) - if (!result) return null + }); + if (!result) return null; // TODO: Map to domain - throw new Error('Mapping from DB to Domain not implemented') + throw new Error("Mapping from DB to Domain not implemented"); } async findByName(name: string): Promise { const result = await db.query.annotationTemplates.findFirst({ where: eq(schema.annotationTemplates.name, name), // with: { ... } - }) - if (!result) return null + }); + if (!result) return null; // TODO: Map to domain - throw new Error('Mapping from DB to Domain not implemented') + throw new Error("Mapping from DB to Domain not implemented"); } async save(template: AnnotationTemplate): Promise { @@ -58,8 +60,8 @@ export class AnnotationTemplateRepository implements IAnnotationTemplateReposito // } // }); - console.log('Saving annotation template:', template.id.toString()) - throw new Error('Save not implemented') + console.log("Saving annotation template:", template.id.toString()); + throw new Error("Save not implemented"); } async delete(id: TID): Promise { @@ -70,7 +72,7 @@ export class AnnotationTemplateRepository implements IAnnotationTemplateReposito // await tx.delete(schema.annotationTemplateFields).where(eq(schema.annotationTemplateFields.templateUri, template.uri)); // await tx.delete(schema.annotationTemplates).where(eq(schema.annotationTemplates.tid, id.toString())); // }); - console.log('Deleting annotation template:', id.toString()) - throw new Error('Delete not implemented') + console.log("Deleting annotation template:", id.toString()); + throw new Error("Delete not implemented"); } } diff --git a/src/annotations/infrastructure/persistence/repositories/index.ts b/src/modules/annotations/infrastructure/persistence/repositories/index.ts similarity index 100% rename from src/annotations/infrastructure/persistence/repositories/index.ts rename to src/modules/annotations/infrastructure/persistence/repositories/index.ts diff --git a/src/shared/core/AppError.ts b/src/shared/core/AppError.ts new file mode 100644 index 00000000..e1d9f52e --- /dev/null +++ b/src/shared/core/AppError.ts @@ -0,0 +1,20 @@ + +import { Result } from "./Result"; +import { UseCaseError } from "./UseCaseError"; + +export namespace AppError { + export class UnexpectedError extends Result { + public constructor (err: any) { + super(false, { + message: `An unexpected error occurred.`, + error: err + } as UseCaseError) + console.log(`[AppError]: An unexpected error occurred`); + console.error(err); + } + + public static create (err: any): UnexpectedError { + return new UnexpectedError(err); + } + } +} \ No newline at end of file diff --git a/src/shared/core/Guard.spec.ts b/src/shared/core/Guard.spec.ts new file mode 100644 index 00000000..22f15b3a --- /dev/null +++ b/src/shared/core/Guard.spec.ts @@ -0,0 +1,84 @@ +import { Guard, GuardResponse } from "./Guard"; +import { Result } from "./Result"; + +describe("guard", () => { + let result: Result | null; + let argName = "testArgument"; + let secondaryArgName = "secondaryTestArgument"; + + beforeEach(() => { + result = null; + }); + + describe("combined results", () => { + it("knows that two successful results equates to success", () => { + result = Guard.combine([Result.ok(), Result.ok()]); + expect(result.isSuccess).toBeTruthy(); + }); + + it("knows that one success, one failure equates to overall failure", () => { + let failureMessage = "This one failed"; + result = Guard.combine([ + Result.ok(), + Result.fail(failureMessage), + ]); + expect(result.isSuccess).toBeFalsy(); + expect(result.isFailure).toBeTruthy(); + expect(result.getErrorValue()).toEqual(failureMessage); + }); + }); + + describe("against null or undefined", () => { + it("knows that value provided equates to success", () => { + result = Guard.againstNullOrUndefined(true, argName); + expect(result.isSuccess).toBeTruthy(); + }); + + it("knows that null value equates to failure", () => { + result = Guard.againstNullOrUndefined(null, argName); + expect(result.isSuccess).toBeFalsy(); + expect(result.getErrorValue()).toEqual(`${argName} is null or undefined`); + }); + + it("knows that undefined value equates to failure", () => { + result = Guard.againstNullOrUndefined(undefined, argName); + expect(result.isSuccess).toBeFalsy(); + expect(result.getErrorValue()).toEqual(`${argName} is null or undefined`); + }); + + it("knows that empty string still equates to success", () => { + result = Guard.againstNullOrUndefined("", argName); + expect(result.isSuccess).toBeTruthy(); + }); + }); + + describe("against null or undefined bulk", () => { + it("knows that values provided equates to success", () => { + result = Guard.againstNullOrUndefinedBulk([ + { argumentName: argName, argument: true }, + { argumentName: secondaryArgName, argument: 12 }, + ]); + expect(result.isSuccess).toBeTruthy(); + }); + + it("knows that a single null value equates to failure", () => { + result = Guard.againstNullOrUndefinedBulk([ + { argumentName: argName, argument: null }, + { argumentName: secondaryArgName, argument: 12 }, + ]); + + expect(result.isSuccess).toBeFalsy(); + expect(result.getErrorValue()).toEqual(`${argName} is null or undefined`); + }); + + it("knows that a single undefined value equates to failure", () => { + result = Guard.againstNullOrUndefinedBulk([ + { argumentName: argName, argument: undefined }, + { argumentName: secondaryArgName, argument: 12 }, + ]); + + expect(result.isSuccess).toBeFalsy(); + expect(result.getErrorValue()).toEqual(`${argName} is null or undefined`); + }); + }); +}); diff --git a/src/shared/core/Guard.ts b/src/shared/core/Guard.ts new file mode 100644 index 00000000..59522768 --- /dev/null +++ b/src/shared/core/Guard.ts @@ -0,0 +1,96 @@ + +export type GuardResponse = string; + +import { Result } from "./Result"; + +export interface IGuardArgument { + argument: any; + argumentName: string; +} + +export type GuardArgumentCollection = IGuardArgument[]; + +export class Guard { + + public static combine (guardResults: Result[]): Result { + for (let result of guardResults) { + if (result.isFailure) return result; + } + + return Result.ok(); + } + + public static greaterThan (minValue: number, actualValue: number): Result { + return actualValue > minValue + ? Result.ok() + : Result.fail(`Number given {${actualValue}} is not greater than {${minValue}}`); + } + + public static againstAtLeast (numChars: number, text: string): Result { + return text.length >= numChars + ? Result.ok() + : Result.fail(`Text is not at least ${numChars} chars.`); + } + + public static againstAtMost (numChars: number, text: string): Result { + return text.length <= numChars + ? Result.ok() + : Result.fail(`Text is greater than ${numChars} chars.`); + } + + public static againstNullOrUndefined (argument: any, argumentName: string): Result { + if (argument === null || argument === undefined) { + return Result.fail(`${argumentName} is null or undefined`) + } else { + return Result.ok(); + } + } + + public static againstNullOrUndefinedBulk(args: GuardArgumentCollection): Result { + for (let arg of args) { + const result = this.againstNullOrUndefined(arg.argument, arg.argumentName); + if (result.isFailure) return result; + } + + return Result.ok(); + } + + public static isOneOf (value: any, validValues: any[], argumentName: string) : Result { + let isValid = false; + for (let validValue of validValues) { + if (value === validValue) { + isValid = true; + } + } + + if (isValid) { + return Result.ok() + } else { + return Result.fail(`${argumentName} isn't oneOf the correct types in ${JSON.stringify(validValues)}. Got "${value}".`); + } + } + + public static inRange (num: number, min: number, max: number, argumentName: string) : Result { + const isInRange = num >= min && num <= max; + if (!isInRange) { + return Result.fail(`${argumentName} is not within range ${min} to ${max}.`); + } else { + return Result.ok() + } + } + + public static allInRange (numbers: number[], min: number, max: number, argumentName: string) : Result { + let failingResult: Result = null; + + for(let num of numbers) { + const numIsInRangeResult = this.inRange(num, min, max, argumentName); + if (!numIsInRangeResult.isFailure) failingResult = numIsInRangeResult; + } + + if (failingResult) { + return Result.fail(`${argumentName} is not within the range.`); + } else { + return Result.ok() + } + } +} \ No newline at end of file diff --git a/src/shared/core/Result.ts b/src/shared/core/Result.ts new file mode 100644 index 00000000..86473992 --- /dev/null +++ b/src/shared/core/Result.ts @@ -0,0 +1,98 @@ +export class Result { + public isSuccess: boolean; + public isFailure: boolean; + private error?: T | string; + private _value?: T; + + public constructor(isSuccess: boolean, error?: T | string, value?: T) { + if (isSuccess && error) { + throw new Error( + "InvalidOperation: A result cannot be successful and contain an error" + ); + } + if (!isSuccess && !error) { + throw new Error( + "InvalidOperation: A failing result needs to contain an error message" + ); + } + + this.isSuccess = isSuccess; + this.isFailure = !isSuccess; + this.error = error; + this._value = value; + + Object.freeze(this); + } + + public getValue(): T { + if (!this.isSuccess || !this._value) { + console.log(this.error); + throw new Error( + "Can't get the value of an error result. Use 'errorValue' instead." + ); + } + + return this._value; + } + + public getErrorValue(): T { + return this.error as T; + } + + public static ok(value?: U): Result { + return new Result(true, undefined, value); + } + + public static fail(error: string): Result { + return new Result(false, error); + } + + public static combine(results: Result[]): Result { + for (let result of results) { + if (result.isFailure) return result; + } + return Result.ok(); + } +} + +export type Either = Left | Right; + +export class Left { + readonly value: L; + + constructor(value: L) { + this.value = value; + } + + isLeft(): this is Left { + return true; + } + + isRight(): this is Right { + return false; + } +} + +export class Right { + readonly value: A; + + constructor(value: A) { + this.value = value; + } + + isLeft(): this is Left { + return false; + } + + isRight(): this is Right { + return true; + } +} + +export const left = (l: L): Either => { + return new Left(l); +}; + +export const right = (a: A): Either => { + return new Right(a); +}; diff --git a/src/shared/core/UseCase.ts b/src/shared/core/UseCase.ts new file mode 100644 index 00000000..1c47453c --- /dev/null +++ b/src/shared/core/UseCase.ts @@ -0,0 +1,4 @@ + +export interface UseCase { + execute (request?: IRequest) : Promise | IResponse; +} \ No newline at end of file diff --git a/src/shared/core/UseCaseError.ts b/src/shared/core/UseCaseError.ts new file mode 100644 index 00000000..11f508f5 --- /dev/null +++ b/src/shared/core/UseCaseError.ts @@ -0,0 +1,12 @@ + +interface IUseCaseError { + message: string; +} + +export abstract class UseCaseError implements IUseCaseError { + public readonly message: string; + + constructor (message: string) { + this.message = message; + } +} diff --git a/src/shared/core/WithChanges.ts b/src/shared/core/WithChanges.ts new file mode 100644 index 00000000..8ad62315 --- /dev/null +++ b/src/shared/core/WithChanges.ts @@ -0,0 +1,22 @@ + +import { Result } from "./Result"; + +export interface WithChanges { + changes: Changes; +} + +export class Changes { + private changes: Result[]; + + constructor () { + this.changes = []; + } + + public addChange (result: Result) : void { + this.changes.push(result); + } + + public getChangeResult (): Result { + return Result.combine(this.changes); + } +} \ No newline at end of file diff --git a/src/shared/domain/AggregateRoot.ts b/src/shared/domain/AggregateRoot.ts new file mode 100644 index 00000000..308050e4 --- /dev/null +++ b/src/shared/domain/AggregateRoot.ts @@ -0,0 +1,41 @@ +import { Entity } from "./Entity"; +import { IDomainEvent } from "./events/IDomainEvent"; +import { DomainEvents } from "./events/DomainEvents"; +import { UniqueEntityID } from "./UniqueEntityID"; + +export abstract class AggregateRoot extends Entity { + private _domainEvents: IDomainEvent[] = []; + + get id(): UniqueEntityID { + return this._id; + } + + get domainEvents(): IDomainEvent[] { + return this._domainEvents; + } + + protected addDomainEvent(domainEvent: IDomainEvent): void { + // Add the domain event to this aggregate's list of domain events + this._domainEvents.push(domainEvent); + // Add this aggregate instance to the domain event's list of aggregates who's + // events it eventually needs to dispatch. + DomainEvents.markAggregateForDispatch(this); + // Log the domain event + this.logDomainEventAdded(domainEvent); + } + + public clearEvents(): void { + this._domainEvents.splice(0, this._domainEvents.length); + } + + private logDomainEventAdded(domainEvent: IDomainEvent): void { + const thisClass = Reflect.getPrototypeOf(this); + const domainEventClass = Reflect.getPrototypeOf(domainEvent); + console.info( + `[Domain Event Created]:`, + thisClass?.constructor.name, + "==>", + domainEventClass?.constructor.name + ); + } +} diff --git a/src/shared/domain/DomainService.ts b/src/shared/domain/DomainService.ts new file mode 100644 index 00000000..3ab03d52 --- /dev/null +++ b/src/shared/domain/DomainService.ts @@ -0,0 +1 @@ +export interface DomainService {} diff --git a/src/shared/domain/Entity.ts b/src/shared/domain/Entity.ts new file mode 100644 index 00000000..850590e8 --- /dev/null +++ b/src/shared/domain/Entity.ts @@ -0,0 +1,31 @@ +import { UniqueEntityID } from "./UniqueEntityID"; + +const isEntity = (v: any): v is Entity => { + return v instanceof Entity; +}; + +export abstract class Entity { + protected readonly _id: UniqueEntityID; + public readonly props: T; + + constructor(props: T, id?: UniqueEntityID) { + this._id = id ? id : new UniqueEntityID(); + this.props = props; + } + + public equals(object?: Entity): boolean { + if (object == null || object == undefined) { + return false; + } + + if (this === object) { + return true; + } + + if (!isEntity(object)) { + return false; + } + + return this._id.equals(object._id); + } +} diff --git a/src/shared/domain/Identifier.ts b/src/shared/domain/Identifier.ts new file mode 100644 index 00000000..61bcb979 --- /dev/null +++ b/src/shared/domain/Identifier.ts @@ -0,0 +1,27 @@ +export class Identifier { + constructor(private value: T) { + this.value = value; + } + + equals(id?: Identifier): boolean { + if (id === null || id === undefined) { + return false; + } + if (!(id instanceof this.constructor)) { + return false; + } + return id.toValue() === this.value; + } + + toString() { + return String(this.value); + } + + /** + * Return raw value of identifier + */ + + toValue(): T { + return this.value; + } +} diff --git a/src/shared/domain/UniqueEntityID.ts b/src/shared/domain/UniqueEntityID.ts new file mode 100644 index 00000000..de9acbf6 --- /dev/null +++ b/src/shared/domain/UniqueEntityID.ts @@ -0,0 +1,8 @@ +import { v4 as uuid } from "uuid"; +import { Identifier } from "./Identifier"; + +export class UniqueEntityID extends Identifier { + constructor(id?: string | number) { + super(id ? id : uuid()); + } +} diff --git a/src/shared/domain/ValueObject.ts b/src/shared/domain/ValueObject.ts new file mode 100644 index 00000000..259ca0ca --- /dev/null +++ b/src/shared/domain/ValueObject.ts @@ -0,0 +1,31 @@ + +interface ValueObjectProps { + [index: string]: any; +} + +/** + * @desc ValueObjects are objects that we determine their + * equality through their structrual property. + */ + +export abstract class ValueObject { + public props: T; + + constructor (props: T) { + let baseProps: any = { + ...props, + } + + this.props = baseProps; + } + + public equals (vo?: ValueObject) : boolean { + if (vo === null || vo === undefined) { + return false; + } + if (vo.props === undefined) { + return false; + } + return JSON.stringify(this.props) === JSON.stringify(vo.props); + } +} \ No newline at end of file diff --git a/src/shared/domain/WatchedList.ts b/src/shared/domain/WatchedList.ts new file mode 100644 index 00000000..f7e5b1c3 --- /dev/null +++ b/src/shared/domain/WatchedList.ts @@ -0,0 +1,95 @@ +export abstract class WatchedList { + public currentItems: T[]; + private initial: T[]; + private new: T[]; + private removed: T[]; + + constructor(initialItems?: T[]) { + this.currentItems = initialItems ? initialItems : []; + this.initial = initialItems ? initialItems : []; + this.new = []; + this.removed = []; + } + + abstract compareItems(a: T, b: T): boolean; + + public getItems(): T[] { + return this.currentItems; + } + + public getNewItems(): T[] { + return this.new; + } + + public getRemovedItems(): T[] { + return this.removed; + } + + private isCurrentItem(item: T): boolean { + return ( + this.currentItems.filter((v: T) => this.compareItems(item, v)).length !== + 0 + ); + } + + private isNewItem(item: T): boolean { + return this.new.filter((v: T) => this.compareItems(item, v)).length !== 0; + } + + private isRemovedItem(item: T): boolean { + return ( + this.removed.filter((v: T) => this.compareItems(item, v)).length !== 0 + ); + } + + private removeFromNew(item: T): void { + this.new = this.new.filter((v) => !this.compareItems(v, item)); + } + + private removeFromCurrent(item: T): void { + this.currentItems = this.currentItems.filter( + (v) => !this.compareItems(item, v) + ); + } + + private removeFromRemoved(item: T): void { + this.removed = this.removed.filter((v) => !this.compareItems(item, v)); + } + + private wasAddedInitially(item: T): boolean { + return ( + this.initial.filter((v: T) => this.compareItems(item, v)).length !== 0 + ); + } + + public exists(item: T): boolean { + return this.isCurrentItem(item); + } + + public add(item: T): void { + if (this.isRemovedItem(item)) { + this.removeFromRemoved(item); + } + + if (!this.isNewItem(item) && !this.wasAddedInitially(item)) { + this.new.push(item); + } + + if (!this.isCurrentItem(item)) { + this.currentItems.push(item); + } + } + + public remove(item: T): void { + this.removeFromCurrent(item); + + if (this.isNewItem(item)) { + this.removeFromNew(item); + return; + } + + if (!this.isRemovedItem(item)) { + this.removed.push(item); + } + } +} diff --git a/src/shared/domain/events/DomainEvents.ts b/src/shared/domain/events/DomainEvents.ts new file mode 100644 index 00000000..7a7deba3 --- /dev/null +++ b/src/shared/domain/events/DomainEvents.ts @@ -0,0 +1,100 @@ +import { IDomainEvent } from "./IDomainEvent"; +import { AggregateRoot } from "../AggregateRoot"; +import { UniqueEntityID } from "../UniqueEntityID"; + +export class DomainEvents { + private static handlersMap: Record< + string, + ((event: IDomainEvent) => void)[] + > = {}; + private static markedAggregates: AggregateRoot[] = []; + + /** + * @method markAggregateForDispatch + * @static + * @desc Called by aggregate root objects that have created domain + * events to eventually be dispatched when the infrastructure commits + * the unit of work. + */ + + public static markAggregateForDispatch(aggregate: AggregateRoot): void { + const aggregateFound = !!this.findMarkedAggregateByID(aggregate.id); + + if (!aggregateFound) { + this.markedAggregates.push(aggregate); + } + } + + private static dispatchAggregateEvents(aggregate: AggregateRoot): void { + aggregate.domainEvents.forEach((event: IDomainEvent) => + this.dispatch(event) + ); + } + + private static removeAggregateFromMarkedDispatchList( + aggregate: AggregateRoot + ): void { + const index = this.markedAggregates.findIndex((a) => a.equals(aggregate)); + this.markedAggregates.splice(index, 1); + } + + private static findMarkedAggregateByID( + id: UniqueEntityID + ): AggregateRoot { + let found: AggregateRoot | null = null; + for (let aggregate of this.markedAggregates) { + if (aggregate.id.equals(id)) { + found = aggregate; + } + } + if (!found) { + throw new Error( + `Aggregate with id ${id.toString()} not found in marked aggregates.` + ); + } + + return found; + } + + public static dispatchEventsForAggregate(id: UniqueEntityID): void { + const aggregate = this.findMarkedAggregateByID(id); + + if (aggregate) { + this.dispatchAggregateEvents(aggregate); + aggregate.clearEvents(); + this.removeAggregateFromMarkedDispatchList(aggregate); + } + } + + public static register( + callback: (event: IDomainEvent) => void, + eventClassName: string + ): void { + if (!this.handlersMap.hasOwnProperty(eventClassName)) { + this.handlersMap[eventClassName] = []; + } + this.handlersMap[eventClassName]?.push(callback); + } + + public static clearHandlers(): void { + this.handlersMap = {}; + } + + public static clearMarkedAggregates(): void { + this.markedAggregates = []; + } + + private static dispatch(event: IDomainEvent): void { + const eventClassName: string = event.constructor.name; + + if (this.handlersMap.hasOwnProperty(eventClassName)) { + const handlers = this.handlersMap[eventClassName]; + if (!handlers) { + return; + } + for (let handler of handlers) { + handler(event); + } + } + } +} diff --git a/src/shared/domain/events/IDomainEvent.ts b/src/shared/domain/events/IDomainEvent.ts new file mode 100644 index 00000000..5bbadfdc --- /dev/null +++ b/src/shared/domain/events/IDomainEvent.ts @@ -0,0 +1,8 @@ + +import { UniqueEntityID } from "../UniqueEntityID"; + +export interface IDomainEvent { + dateTimeOccurred: Date; + getAggregateId (): UniqueEntityID; +} + diff --git a/src/shared/domain/events/IHandle.ts b/src/shared/domain/events/IHandle.ts new file mode 100644 index 00000000..5d7d6919 --- /dev/null +++ b/src/shared/domain/events/IHandle.ts @@ -0,0 +1,6 @@ + +import { IDomainEvent } from "./IDomainEvent"; + +export interface IHandle { + setupSubscriptions(): void; +} diff --git a/src/shared/infra/Mapper.ts b/src/shared/infra/Mapper.ts new file mode 100644 index 00000000..16affe0c --- /dev/null +++ b/src/shared/infra/Mapper.ts @@ -0,0 +1 @@ +export interface Mapper {} diff --git a/src/shared/infra/http/models/BaseController.ts b/src/shared/infra/http/models/BaseController.ts new file mode 100644 index 00000000..f61419f3 --- /dev/null +++ b/src/shared/infra/http/models/BaseController.ts @@ -0,0 +1,73 @@ + +import * as express from 'express' + +export abstract class BaseController { + + protected abstract executeImpl (req: express.Request, res: express.Response): Promise; + + public async execute (req: express.Request, res: express.Response): Promise { + try { + await this.executeImpl(req, res); + } catch (err) { + console.log(`[BaseController]: Uncaught controller error`); + console.log(err); + this.fail(res, 'An unexpected error occurred') + } + } + + public static jsonResponse (res: express.Response, code: number, message: string) { + return res.status(code).json({ message }) + } + + public ok (res: express.Response, dto?: T) { + if (!!dto) { + res.type('application/json'); + return res.status(200).json(dto); + } else { + return res.sendStatus(200); + } + } + + public created (res: express.Response) { + return res.sendStatus(201); + } + + public clientError (res: express.Response, message?: string) { + return BaseController.jsonResponse(res, 400, message ? message : 'Unauthorized'); + } + + public unauthorized (res: express.Response, message?: string) { + return BaseController.jsonResponse(res, 401, message ? message : 'Unauthorized'); + } + + public paymentRequired (res: express.Response, message?: string) { + return BaseController.jsonResponse(res, 402, message ? message : 'Payment required'); + } + + public forbidden (res: express.Response, message?: string) { + return BaseController.jsonResponse(res, 403, message ? message : 'Forbidden'); + } + + public notFound (res: express.Response, message?: string) { + return BaseController.jsonResponse(res, 404, message ? message : 'Not found'); + } + + public conflict (res: express.Response, message?: string) { + return BaseController.jsonResponse(res, 409, message ? message : 'Conflict'); + } + + public tooMany (res: express.Response, message?: string) { + return BaseController.jsonResponse(res, 429, message ? message : 'Too many requests'); + } + + public todo (res: express.Response) { + return BaseController.jsonResponse(res, 400, 'TODO'); + } + + public fail (res: express.Response, error: Error | string) { + console.log(error); + return res.status(500).json({ + message: error.toString() + }) + } +} \ No newline at end of file diff --git a/tests/annotations/application/use-cases/CreateAnnotationFieldUseCase.test.ts b/tests/annotations/application/use-cases/CreateAnnotationFieldUseCase.test.ts index 2d6450b2..465c615e 100644 --- a/tests/annotations/application/use-cases/CreateAnnotationFieldUseCase.test.ts +++ b/tests/annotations/application/use-cases/CreateAnnotationFieldUseCase.test.ts @@ -1,10 +1,10 @@ -import { CreateAnnotationFieldUseCase } from "../../../../src/annotations/application/use-cases/CreateAnnotationFieldUseCase"; -import { IAnnotationFieldRepository } from "../../../../src/annotations/application/repositories/IAnnotationFieldRepository"; -import { AnnotationField } from "../../../../src/annotations/domain/aggregates/AnnotationField"; -import { AnnotationFieldInputDTO } from "../../../../src/annotations/application/dtos/AnnotationFieldDTO"; -import { DyadFieldDef } from "../../../../src/annotations/domain/value-objects/FieldDefinition"; +import { CreateAnnotationFieldUseCase } from "../../../../src/modules/annotations/application/use-cases/CreateAnnotationFieldUseCase"; +import { IAnnotationFieldRepository } from "../../../../src/modules/annotations/application/repositories/IAnnotationFieldRepository"; +import { AnnotationField } from "../../../../src/modules/annotations/domain/aggregates/AnnotationField"; +import { AnnotationFieldInputDTO } from "../../../../src/modules/annotations/application/dtos/AnnotationFieldDTO"; +import { DyadFieldDef } from "../../../../src/modules/annotations/domain/value-objects/FieldDefinition"; import { TID } from "../../../../src/atproto/domain/value-objects/TID"; -import { InMemoryAnnotationFieldRepository } from "../../../infrastructure/persistence/InMemoryAnnotationFieldRepository"; // Import the in-memory repo +import { InMemoryAnnotationFieldRepository } from "tests/annotations/infrastructure/persistence/InMemoryAnnotationFieldRepository"; // Mock the mapper (static methods) - Still needed as the use case uses it // Adjust path if needed diff --git a/tests/annotations/application/use-cases/GetAnnotationFieldUseCase.test.ts b/tests/annotations/application/use-cases/GetAnnotationFieldUseCase.test.ts index 8645d2fa..0a552c15 100644 --- a/tests/annotations/application/use-cases/GetAnnotationFieldUseCase.test.ts +++ b/tests/annotations/application/use-cases/GetAnnotationFieldUseCase.test.ts @@ -1,8 +1,8 @@ -import { GetAnnotationFieldUseCase } from '../../../../src/annotations/application/use-cases/GetAnnotationFieldUseCase' -import { IAnnotationFieldRepository } from '../../../../src/annotations/application/repositories/IAnnotationFieldRepository' -import { AnnotationField } from '../../../../src/annotations/domain/aggregates/AnnotationField' -import { RatingFieldDef } from '../../../../src/annotations/domain/value-objects/FieldDefinition' -import { TID } from '../../../../src/atproto/domain/value-objects/TID' +import { GetAnnotationFieldUseCase } from "../../../../src/modules/annotations/application/use-cases/GetAnnotationFieldUseCase"; +import { IAnnotationFieldRepository } from "../../../../src/modules/annotations/application/repositories/IAnnotationFieldRepository"; +import { AnnotationField } from "../../../../src/modules/annotations/domain/aggregates/AnnotationField"; +import { RatingFieldDef } from "../../../../src/modules/annotations/domain/value-objects/FieldDefinition"; +import { TID } from "../../../../src/atproto/domain/value-objects/TID"; // Mock the repository const mockFieldRepo: jest.Mocked = { @@ -11,81 +11,84 @@ const mockFieldRepo: jest.Mocked = { findByName: jest.fn(), save: jest.fn(), delete: jest.fn(), -} +}; // Mock the mapper (static methods) // Adjust path if needed -jest.mock('../../../../src/annotations/infrastructure/persistence/drizzle/mappers/AnnotationFieldMapper', () => ({ - AnnotationFieldMapper: { - toDTO: jest.fn((field: AnnotationField) => ({ - id: field.id.toString(), - name: field.name, - description: field.description, - definition: { - $type: field.definition.$type, - // Add mock mapping for specific types if needed for assertions - }, - createdAt: field.createdAt.toISOString(), - })), - }, -})) - -describe('GetAnnotationFieldUseCase', () => { - let getAnnotationFieldUseCase: GetAnnotationFieldUseCase +jest.mock( + "../../../../src/annotations/infrastructure/persistence/drizzle/mappers/AnnotationFieldMapper", + () => ({ + AnnotationFieldMapper: { + toDTO: jest.fn((field: AnnotationField) => ({ + id: field.id.toString(), + name: field.name, + description: field.description, + definition: { + $type: field.definition.$type, + // Add mock mapping for specific types if needed for assertions + }, + createdAt: field.createdAt.toISOString(), + })), + }, + }) +); + +describe("GetAnnotationFieldUseCase", () => { + let getAnnotationFieldUseCase: GetAnnotationFieldUseCase; beforeEach(() => { // Reset mocks before each test - jest.clearAllMocks() - getAnnotationFieldUseCase = new GetAnnotationFieldUseCase(mockFieldRepo) - }) + jest.clearAllMocks(); + getAnnotationFieldUseCase = new GetAnnotationFieldUseCase(mockFieldRepo); + }); - it('should return the annotation field DTO if found', async () => { - const fieldId = TID.create() + it("should return the annotation field DTO if found", async () => { + const fieldId = TID.create(); const mockField = AnnotationField.create({ id: fieldId, - name: 'Test Rating Field', - description: 'A field for testing ratings', + name: "Test Rating Field", + description: "A field for testing ratings", definition: new RatingFieldDef(), // Example definition - }) + }); // Mock the repository to return the field - mockFieldRepo.findById.mockResolvedValue(mockField) + mockFieldRepo.findById.mockResolvedValue(mockField); - const result = await getAnnotationFieldUseCase.execute(fieldId) + const result = await getAnnotationFieldUseCase.execute(fieldId); // 1. Verify repository's findById was called correctly - expect(mockFieldRepo.findById).toHaveBeenCalledTimes(1) - expect(mockFieldRepo.findById).toHaveBeenCalledWith(fieldId) + expect(mockFieldRepo.findById).toHaveBeenCalledTimes(1); + expect(mockFieldRepo.findById).toHaveBeenCalledWith(fieldId); // 2. Verify the mapper was called // Cannot directly assert on the static mock call count easily without more setup, // but we can verify the result which implies the mapper was called. // 3. Verify the returned DTO matches the mock field data - expect(result).toBeDefined() - expect(result?.id).toBe(fieldId.toString()) - expect(result?.name).toBe(mockField.name) - expect(result?.description).toBe(mockField.description) - expect(result?.definition.$type).toBe(mockField.definition.$type) - expect(result?.createdAt).toBe(mockField.createdAt.toISOString()) - }) + expect(result).toBeDefined(); + expect(result?.id).toBe(fieldId.toString()); + expect(result?.name).toBe(mockField.name); + expect(result?.description).toBe(mockField.description); + expect(result?.definition.$type).toBe(mockField.definition.$type); + expect(result?.createdAt).toBe(mockField.createdAt.toISOString()); + }); - it('should return null if the annotation field is not found', async () => { - const fieldId = TID.create() + it("should return null if the annotation field is not found", async () => { + const fieldId = TID.create(); // Mock the repository to return null (not found) - mockFieldRepo.findById.mockResolvedValue(null) + mockFieldRepo.findById.mockResolvedValue(null); - const result = await getAnnotationFieldUseCase.execute(fieldId) + const result = await getAnnotationFieldUseCase.execute(fieldId); // 1. Verify repository's findById was called correctly - expect(mockFieldRepo.findById).toHaveBeenCalledTimes(1) - expect(mockFieldRepo.findById).toHaveBeenCalledWith(fieldId) + expect(mockFieldRepo.findById).toHaveBeenCalledTimes(1); + expect(mockFieldRepo.findById).toHaveBeenCalledWith(fieldId); // 2. Verify the result is null - expect(result).toBeNull() - }) + expect(result).toBeNull(); + }); // Add more tests for: // - Repository errors during findById -}) +}); diff --git a/tests/annotations/infrastructure/persistence/InMemoryAnnotationFieldRepository.ts b/tests/annotations/infrastructure/persistence/InMemoryAnnotationFieldRepository.ts index af1ad1e1..253e58cd 100644 --- a/tests/annotations/infrastructure/persistence/InMemoryAnnotationFieldRepository.ts +++ b/tests/annotations/infrastructure/persistence/InMemoryAnnotationFieldRepository.ts @@ -1,75 +1,75 @@ -import { AnnotationField } from '../../../../src/annotations/domain/aggregates/AnnotationField' -import { IAnnotationFieldRepository } from '../../../../src/annotations/application/repositories/IAnnotationFieldRepository' -import { TID } from '../../../../src/atproto/domain/value-objects/TID' +import { AnnotationField } from "../../../../src/modules/annotations/domain/aggregates/AnnotationField"; +import { IAnnotationFieldRepository } from "../../../../src/modules/annotations/application/repositories/IAnnotationFieldRepository"; +import { TID } from "../../../../src/atproto/domain/value-objects/TID"; export class InMemoryAnnotationFieldRepository implements IAnnotationFieldRepository { - private fields: Map = new Map() + private fields: Map = new Map(); // We need a way to look up by URI as well for findByUri - private uriToIdMap: Map = new Map() + private uriToIdMap: Map = new Map(); async findById(id: TID): Promise { - const field = this.fields.get(id.toString()) + const field = this.fields.get(id.toString()); // Return a clone to prevent mutation of the stored object - return field ? this.clone(field) : null + return field ? this.clone(field) : null; } async findByUri(uri: string): Promise { - const id = this.uriToIdMap.get(uri) - if (!id) return null - const field = this.fields.get(id) - return field ? this.clone(field) : null + const id = this.uriToIdMap.get(uri); + if (!id) return null; + const field = this.fields.get(id); + return field ? this.clone(field) : null; } async findByName(name: string): Promise { for (const field of this.fields.values()) { if (field.name === name) { - return this.clone(field) + return this.clone(field); } } - return null + return null; } async save(field: AnnotationField): Promise { // Store a clone to prevent external mutations affecting the stored object - const fieldToStore = this.clone(field) - this.fields.set(fieldToStore.id.toString(), fieldToStore) + const fieldToStore = this.clone(field); + this.fields.set(fieldToStore.id.toString(), fieldToStore); // Assume URI is derived from ID for simplicity in this mock, // or requires a specific format. Adjust if URI is independent. // Example: Construct a plausible AT URI (replace with actual logic if needed) - const uri = `at://did:plc:fake/${fieldToStore.id.toString()}/app.annos.annotationField` - this.uriToIdMap.set(uri, fieldToStore.id.toString()) + const uri = `at://did:plc:fake/${fieldToStore.id.toString()}/app.annos.annotationField`; + this.uriToIdMap.set(uri, fieldToStore.id.toString()); } async delete(id: TID): Promise { - const field = this.fields.get(id.toString()) + const field = this.fields.get(id.toString()); if (field) { // Find and remove from uriToIdMap as well - let uriToRemove: string | null = null + let uriToRemove: string | null = null; for (const [uri, storedId] of this.uriToIdMap.entries()) { if (storedId === id.toString()) { - uriToRemove = uri - break + uriToRemove = uri; + break; } } if (uriToRemove) { - this.uriToIdMap.delete(uriToRemove) + this.uriToIdMap.delete(uriToRemove); } - this.fields.delete(id.toString()) + this.fields.delete(id.toString()); } } // Helper to clear the store between tests public clear(): void { - this.fields.clear() - this.uriToIdMap.clear() + this.fields.clear(); + this.uriToIdMap.clear(); } // Helper to get the raw stored field for assertions (use carefully) public getStoredField(id: TID): AnnotationField | undefined { - return this.fields.get(id.toString()) + return this.fields.get(id.toString()); } // Simple cloning function (adjust if deep cloning is needed for complex objects) @@ -87,7 +87,7 @@ export class InMemoryAnnotationFieldRepository description: field.description, definition: field.definition, // Assuming definition is immutable or cloneable createdAt: field.createdAt, - } + }; // We need access to the constructor or a hydrate method. // Let's assume a static hydrate method exists for this example: // return AnnotationField.hydrate(props); @@ -101,14 +101,13 @@ export class InMemoryAnnotationFieldRepository // Let's stick to returning a plain object copy for now, acknowledging limitations // This won't be an instance of AnnotationField, which might break some tests. // The BEST way is a proper hydrate/clone on the Aggregate. - const clonedProps = JSON.parse(JSON.stringify(props)) // Basic deep clone + const clonedProps = JSON.parse(JSON.stringify(props)); // Basic deep clone // Reconstruct value objects if necessary (TID, FieldDefinition) return AnnotationField.create({ id: TID.fromString(clonedProps.id.tid), // Assuming TID has fromString/tid property name: clonedProps.name, description: clonedProps.description, definition: clonedProps.definition, // Assuming definition is plain data - createdAt: new Date(clonedProps.createdAt), - }) + }); } }