diff --git a/docs/architecture.md b/docs/architecture.md index c5fa9be..c7a8ed0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -34,16 +34,31 @@ The `open_educational_resources` table stores processed OER data with denormaliz | `amb_date_created` | Timestamp | Yes | AMB Event | Resource creation date | | `amb_date_published` | Timestamp | Yes | AMB Event | Publication date | | `amb_date_modified` | Timestamp | Yes | AMB Event | Last modification date (for update tracking) | -| **Event References** | -| `event_amb_id` | Text | Yes, FK | System | Foreign key to AMB event in `nostr_events` | -| `event_file_id` | Text | Yes, FK | System | Foreign key to file event in `nostr_events` (nullable) | | **System Fields** | | `created_at` | Timestamp | Yes | System | Record creation timestamp | | `updated_at` | Timestamp | - | System | Last update timestamp | -| `source` | Text | Yes | System | Origin identifier (e.g., "nostr") | +| `source_name` | Text | Yes | System | Origin identifier (e.g., "nostr") | | `name` | Text | - | AMB Event | Resource name/title | | `attribution` | Text | - | AMB Event | Attribution/copyright notice | +### OER Sources Table + +The `oer_sources` table stores raw Nostr events and links them to OER records: + +| Field | Type | Description | +|-------|------|-------------| +| `id` | UUID | Auto-generated primary key | +| `oer_id` | UUID | Foreign key to `open_educational_resources` (nullable, cascade delete) | +| `source_name` | Text | Origin identifier (e.g., "nostr") | +| `source_identifier` | Text | Unique identifier (e.g., `event:`) | +| `source_data` | JSONB | Complete raw event data | +| `status` | Text | Processing status: `pending` or `processed` | +| `source_uri` | Text | Resource URI from the event | +| `source_timestamp` | BigInt | Event timestamp (created_at) | +| `source_record_type` | Text | Event kind (e.g., "30142", "1063") | +| `created_at` | Timestamp | Record creation timestamp | +| `updated_at` | Timestamp | Last update timestamp | + **Design Rationale**: - **Denormalization**: Frequently queried fields are extracted from events for indexing, enabling fast searches by license, level, audience, and dates - **JSONB Storage**: Complete AMB metadata is preserved to avoid data loss and support future query needs without schema changes diff --git a/docs/nostr-events.md b/docs/nostr-events.md index 7645d2f..6e0e522 100644 --- a/docs/nostr-events.md +++ b/docs/nostr-events.md @@ -88,10 +88,11 @@ Events may arrive in any order and are processed independently: ## Event Storage -All Nostr events (kinds 30142, 1063, and deletion events) are stored in the `nostr_events` table with: -- Complete raw event data (JSONB format) -- Indexed fields for efficient querying (event ID, kind, pubkey, created_at) -- Source relay URL for traceability -- Ingestion timestamp for auditing - -Events are retained until explicitly deleted via NIP-09 deletion events. +All Nostr events (kinds 30142, 1063, and deletion events) are stored in the `oer_sources` table with: +- Complete raw event data in `source_data` (JSONB format) +- Source identifier (`event:`) for lookups +- Record type (event kind) for filtering +- Processing status (`pending`, `processed`) +- Link to OER record when processed + +Events are linked to their corresponding OER records via the `oer_id` foreign key. When an OER is deleted, all associated sources are cascade-deleted. diff --git a/openspec/specs/aggregator-server/spec.md b/openspec/specs/aggregator-server/spec.md index 603c29c..28668df 100644 --- a/openspec/specs/aggregator-server/spec.md +++ b/openspec/specs/aggregator-server/spec.md @@ -409,13 +409,13 @@ The application MUST handle kind 5 (NIP-09) deletion events from Nostr relays, v - **AND** the deletion is logged with event ID and kind - **AND** all related data is removed atomically -#### Scenario: Nullify file metadata for deleted file events +#### Scenario: Handle file event deletion - **GIVEN** a valid deletion request for a kind 1063 (file) event -- **WHEN** the file event is deleted -- **THEN** file metadata fields (file_mime_type, file_size, file_dim, file_alt) are nullified in all OER records referencing this file -- **AND** the file event reference (event_file_id) is set to null by database constraint -- **AND** the OER records remain intact with nullified file metadata -- **AND** the deletion is logged with affected OER count +- **WHEN** the file event source is deleted +- **THEN** the file event source is removed from the `oer_sources` table +- **AND** file metadata fields on the OER record are not automatically nullified +- **AND** the OER record remains intact +- **AND** the deletion is logged #### Scenario: Delete other event types directly - **GIVEN** a valid deletion request for an event that is not AMB or file type diff --git a/packages/oer-finder-api-client/dist/generated/schema.d.ts b/packages/oer-finder-api-client/dist/generated/schema.d.ts index 8a68da7..697ac2f 100644 --- a/packages/oer-finder-api-client/dist/generated/schema.d.ts +++ b/packages/oer-finder-api-client/dist/generated/schema.d.ts @@ -458,15 +458,10 @@ export interface components { */ source: string; /** - * @description Nostr event ID for the AMB event - * @example abc123def456 + * @description URL to the resource landing page on the original source website (e.g., Openverse, Flickr) + * @example https://www.flickr.com/photos/12345/67890 */ - event_amb_id: Record | null; - /** - * @description Nostr event ID for the file event - * @example xyz789uvw012 - */ - event_file_id: Record | null; + foreign_landing_url: string | null; /** * Format: date-time * @description Timestamp when the record was created in the database diff --git a/packages/oer-finder-api-client/dist/generated/schema.d.ts.map b/packages/oer-finder-api-client/dist/generated/schema.d.ts.map index 28d532f..ac36416 100644 --- a/packages/oer-finder-api-client/dist/generated/schema.d.ts.map +++ b/packages/oer-finder-api-client/dist/generated/schema.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../generated/schema.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../generated/schema.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/oer-finder-api-client/generated/schema.ts b/packages/oer-finder-api-client/generated/schema.ts index 22b2e3f..c189f44 100644 --- a/packages/oer-finder-api-client/generated/schema.ts +++ b/packages/oer-finder-api-client/generated/schema.ts @@ -463,16 +463,6 @@ export interface components { * @example https://www.flickr.com/photos/12345/67890 */ foreign_landing_url: string | null; - /** - * @description Nostr event ID for the AMB event - * @example abc123def456 - */ - event_amb_id: Record | null; - /** - * @description Nostr event ID for the file event - * @example xyz789uvw012 - */ - event_file_id: Record | null; /** * Format: date-time * @description Timestamp when the record was created in the database diff --git a/packages/oer-finder-api-client/openapi.json b/packages/oer-finder-api-client/openapi.json index a704f2a..55118eb 100644 --- a/packages/oer-finder-api-client/openapi.json +++ b/packages/oer-finder-api-client/openapi.json @@ -684,18 +684,6 @@ "example": "https://www.flickr.com/photos/12345/67890", "nullable": true }, - "event_amb_id": { - "type": "object", - "description": "Nostr event ID for the AMB event", - "example": "abc123def456", - "nullable": true - }, - "event_file_id": { - "type": "object", - "description": "Nostr event ID for the file event", - "example": "xyz789uvw012", - "nullable": true - }, "created_at": { "format": "date-time", "type": "string", @@ -755,8 +743,6 @@ "educational_level_uri", "source", "foreign_landing_url", - "event_amb_id", - "event_file_id", "created_at", "updated_at", "images", diff --git a/packages/oer-finder-api-client/package.json b/packages/oer-finder-api-client/package.json index ba001c0..7fdda70 100644 --- a/packages/oer-finder-api-client/package.json +++ b/packages/oer-finder-api-client/package.json @@ -1,6 +1,6 @@ { "name": "@edufeed-org/oer-finder-api-client", - "version": "0.0.4", + "version": "0.0.5", "description": "Auto-generated API client for OER Aggregator", "author": "B310 Digital GmbH", "license": "MIT", diff --git a/packages/oer-finder-plugin/dist/LICENSES.txt b/packages/oer-finder-plugin/dist/LICENSES.txt index 165da4b..c70c0ed 100644 --- a/packages/oer-finder-plugin/dist/LICENSES.txt +++ b/packages/oer-finder-plugin/dist/LICENSES.txt @@ -160,7 +160,7 @@ SOFTWARE. --- Name: @edufeed-org/oer-finder-api-client -Version: 0.0.4 +Version: 0.0.5 License: MIT Private: false Description: Auto-generated API client for OER Aggregator diff --git a/packages/oer-finder-plugin/package.json b/packages/oer-finder-plugin/package.json index a0c87e2..567e8ef 100644 --- a/packages/oer-finder-plugin/package.json +++ b/packages/oer-finder-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@edufeed-org/oer-finder-plugin", - "version": "0.0.6", + "version": "0.0.7", "description": "Web Components plugin for OER Aggregator", "author": "B310 Digital GmbH", "license": "MIT", diff --git a/packages/oer-finder-plugin/src/oer-card/OerCard.test.ts b/packages/oer-finder-plugin/src/oer-card/OerCard.test.ts index 9155ae8..13dccdd 100644 --- a/packages/oer-finder-plugin/src/oer-card/OerCard.test.ts +++ b/packages/oer-finder-plugin/src/oer-card/OerCard.test.ts @@ -33,8 +33,6 @@ describe('OerCard', () => { educational_level_uri: null, source: 'nostr', foreign_landing_url: null, - event_amb_id: null, - event_file_id: null, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', images: { diff --git a/packages/oer-finder-plugin/src/oer-list/OerList.test.ts b/packages/oer-finder-plugin/src/oer-list/OerList.test.ts index ceb4bd4..c48c042 100644 --- a/packages/oer-finder-plugin/src/oer-list/OerList.test.ts +++ b/packages/oer-finder-plugin/src/oer-list/OerList.test.ts @@ -33,8 +33,6 @@ describe('OerList', () => { educational_level_uri: null, source: 'nostr', foreign_landing_url: null, - event_amb_id: null, - event_file_id: null, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', images: null, @@ -61,8 +59,6 @@ describe('OerList', () => { educational_level_uri: null, source: 'nostr', foreign_landing_url: null, - event_amb_id: null, - event_file_id: null, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', images: { diff --git a/src/app.module.ts b/src/app.module.ts index 195674f..29f320c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,7 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { ThrottlerModule } from '@nestjs/throttler'; import { AppController } from './app.controller'; import { AppService } from './app.service'; -import { NostrEvent } from './nostr/entities/nostr-event.entity'; +import { OerSource } from './oer/entities/oer-source.entity'; import { OpenEducationalResource } from './oer/entities/open-educational-resource.entity'; import { NostrModule } from './nostr/nostr.module'; import { OerModule } from './oer/oer.module'; @@ -47,7 +47,7 @@ import { validateEnv } from './config/env.validation'; username: configService.get('database.username'), password: configService.get('database.password'), database: configService.get('database.database'), - entities: [NostrEvent, OpenEducationalResource], + entities: [OerSource, OpenEducationalResource], synchronize: configService.get('app.nodeEnv') === 'test', logging: configService.get('app.nodeEnv') === 'production' diff --git a/src/data-source.ts b/src/data-source.ts index 8479a10..d40c0b6 100644 --- a/src/data-source.ts +++ b/src/data-source.ts @@ -1,5 +1,5 @@ import { DataSource } from 'typeorm'; -import { NostrEvent } from './nostr/entities/nostr-event.entity'; +import { OerSource } from './oer/entities/oer-source.entity'; import { OpenEducationalResource } from './oer/entities/open-educational-resource.entity'; import { getDatabaseConfig } from './config/database.config'; @@ -16,7 +16,7 @@ const migrationPath = isCompiled const AppDataSource = new DataSource({ type: 'postgres', ...dbConfig, - entities: [NostrEvent, OpenEducationalResource], + entities: [OerSource, OpenEducationalResource], migrations: [migrationPath], synchronize: false, }); diff --git a/src/migrations/1767355896158-AddOerSourcesTable.ts b/src/migrations/1767355896158-AddOerSourcesTable.ts new file mode 100644 index 0000000..bd559bc --- /dev/null +++ b/src/migrations/1767355896158-AddOerSourcesTable.ts @@ -0,0 +1,165 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddOerSourcesTable1767355896158 implements MigrationInterface { + name = 'AddOerSourcesTable1767355896158'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "open_educational_resources" DROP CONSTRAINT "FK_b69a39222fb7793c0dc1ae58ada"`, + ); + await queryRunner.query( + `ALTER TABLE "open_educational_resources" DROP CONSTRAINT "FK_6f4abb483f5b9e7ef9b15a29575"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_82cfb2c3e01081b46485c669bb"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_b69a39222fb7793c0dc1ae58ad"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_6f4abb483f5b9e7ef9b15a2957"`, + ); + await queryRunner.query(`DROP INDEX "public"."IDX_license_uri"`); + await queryRunner.query(`DROP INDEX "public"."IDX_free_to_use"`); + await queryRunner.query(`DROP INDEX "public"."IDX_oer_source"`); + await queryRunner.query( + `CREATE TABLE "oer_sources" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "oer_id" uuid, "source_name" text NOT NULL, "source_identifier" text, "source_data" jsonb NOT NULL, "status" text NOT NULL DEFAULT 'processed', "source_uri" text, "source_timestamp" bigint, "source_record_type" text, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_3612cee39d6cd8aefa6b976fa35" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_16f6043163502f0aeda104772f" ON "oer_sources" ("oer_id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_09fe53efc402e7d2bb8248689d" ON "oer_sources" ("source_name") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_882c2d8f5d24da3c0829124fec" ON "oer_sources" ("status") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_c0d9f2eef0492502ef272119bd" ON "oer_sources" ("source_uri") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_8514dce00ba8f9a9c5c9c7a1f7" ON "oer_sources" ("source_timestamp") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_fffcc98246c761891de52746b2" ON "oer_sources" ("source_record_type") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_ed712037fa3a5ca3876be0ec61" ON "oer_sources" ("created_at") `, + ); + await queryRunner.query( + `ALTER TABLE "open_educational_resources" DROP COLUMN "event_amb_id"`, + ); + await queryRunner.query( + `ALTER TABLE "open_educational_resources" DROP COLUMN "event_file_id"`, + ); + await queryRunner.query( + `ALTER TABLE "open_educational_resources" DROP COLUMN "source"`, + ); + await queryRunner.query( + `ALTER TABLE "open_educational_resources" ADD "source_name" text NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "open_educational_resources" DROP CONSTRAINT "UQ_82cfb2c3e01081b46485c669bb8"`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_82cfb2c3e01081b46485c669bb" ON "open_educational_resources" ("url") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_7724ea526569ff0d2928552949" ON "open_educational_resources" ("source_name") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_cae7b8c16b2b3b728e13748a4b" ON "open_educational_resources" ("license_uri") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_feb3b8c9620479cf6044bd9ec5" ON "open_educational_resources" ("free_to_use") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_9e6a2067565cb0505a9d9b31ed" ON "open_educational_resources" ("url", "source_name") `, + ); + await queryRunner.query( + `ALTER TABLE "oer_sources" ADD CONSTRAINT "FK_16f6043163502f0aeda104772f2" FOREIGN KEY ("oer_id") REFERENCES "open_educational_resources"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "oer_sources" DROP CONSTRAINT "FK_16f6043163502f0aeda104772f2"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_9e6a2067565cb0505a9d9b31ed"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_feb3b8c9620479cf6044bd9ec5"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_cae7b8c16b2b3b728e13748a4b"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_7724ea526569ff0d2928552949"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_82cfb2c3e01081b46485c669bb"`, + ); + await queryRunner.query( + `ALTER TABLE "open_educational_resources" ADD CONSTRAINT "UQ_82cfb2c3e01081b46485c669bb8" UNIQUE ("url")`, + ); + await queryRunner.query( + `ALTER TABLE "open_educational_resources" DROP COLUMN "source_name"`, + ); + await queryRunner.query( + `ALTER TABLE "open_educational_resources" ADD "source" text`, + ); + await queryRunner.query( + `ALTER TABLE "open_educational_resources" ADD "event_file_id" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "open_educational_resources" ADD "event_amb_id" character varying`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_ed712037fa3a5ca3876be0ec61"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_fffcc98246c761891de52746b2"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_8514dce00ba8f9a9c5c9c7a1f7"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_c0d9f2eef0492502ef272119bd"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_882c2d8f5d24da3c0829124fec"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_09fe53efc402e7d2bb8248689d"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_16f6043163502f0aeda104772f"`, + ); + await queryRunner.query(`DROP TABLE "oer_sources"`); + await queryRunner.query( + `CREATE INDEX "IDX_oer_source" ON "open_educational_resources" ("source") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_free_to_use" ON "open_educational_resources" ("free_to_use") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_license_uri" ON "open_educational_resources" ("license_uri") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_6f4abb483f5b9e7ef9b15a2957" ON "open_educational_resources" ("event_file_id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_b69a39222fb7793c0dc1ae58ad" ON "open_educational_resources" ("event_amb_id") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_82cfb2c3e01081b46485c669bb" ON "open_educational_resources" ("url") `, + ); + await queryRunner.query( + `ALTER TABLE "open_educational_resources" ADD CONSTRAINT "FK_6f4abb483f5b9e7ef9b15a29575" FOREIGN KEY ("event_file_id") REFERENCES "nostr_events"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "open_educational_resources" ADD CONSTRAINT "FK_b69a39222fb7793c0dc1ae58ada" FOREIGN KEY ("event_amb_id") REFERENCES "nostr_events"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } +} diff --git a/src/migrations/1767356078644-DropNostrEventsTable.ts b/src/migrations/1767356078644-DropNostrEventsTable.ts new file mode 100644 index 0000000..f227786 --- /dev/null +++ b/src/migrations/1767356078644-DropNostrEventsTable.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class DropNostrEventsTable1767356078644 implements MigrationInterface { + name = 'DropNostrEventsTable1767356078644'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "nostr_events"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "nostr_events" ("id" character varying(64) NOT NULL, "kind" integer NOT NULL, "pubkey" character varying(64) NOT NULL, "created_at" bigint NOT NULL, "content" text NOT NULL, "tags" jsonb NOT NULL, "raw_event" jsonb NOT NULL, "relay_url" character varying(512), "ingested_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_49f0f107f9961908d04c2192f79" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_600afd5845956fd1b4e33bcb43" ON "nostr_events" ("kind")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_db3ce841de05354b31d814ed31" ON "nostr_events" ("pubkey")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_c799981b73ae1d2b419a19afa3" ON "nostr_events" ("created_at")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_03b6fcac4d766f960c1b47201f" ON "nostr_events" ("relay_url")`, + ); + } +} diff --git a/src/nostr/__tests__/event-deletion.service.spec.ts b/src/nostr/__tests__/event-deletion.service.spec.ts index 1a6279c..c9ab3ce 100644 --- a/src/nostr/__tests__/event-deletion.service.spec.ts +++ b/src/nostr/__tests__/event-deletion.service.spec.ts @@ -2,44 +2,102 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { EventDeletionService } from '../services/event-deletion.service'; -import { NostrEvent } from '../entities/nostr-event.entity'; import { OpenEducationalResource } from '../../oer/entities/open-educational-resource.entity'; +import { OerSource } from '../../oer/entities/oer-source.entity'; import type { Event } from 'nostr-tools/core'; import { EVENT_AMB_KIND, EVENT_FILE_KIND, } from '../constants/event-kinds.constants'; -import { EventFactory, NostrEventFactory } from '../../../test/fixtures'; +import { EventFactory } from '../../../test/fixtures'; +import { SOURCE_NAME_NOSTR } from '../../oer/constants'; + +/** + * Represents a Nostr event stored in source_data. + */ +interface NostrEventData { + id: string; + kind: number; + pubkey: string; + created_at: number; + content: string; + tags: string[][]; + sig: string; +} // Type for accessing private methods in tests interface EventDeletionServiceWithPrivate { extractEventReferences(deleteEvent: Event): string[]; validateDeletionRequest( deleteEvent: Event, - referencedEvent: NostrEvent, + referencedEventData: NostrEventData, ): boolean; } +/** + * Creates a mock OerSource with embedded Nostr event data. + */ +function createMockOerSource( + eventId: string, + kind: number, + pubkey: string, + overrides: Partial = {}, +): OerSource { + const eventData: NostrEventData = { + id: eventId, + kind, + pubkey, + created_at: Math.floor(Date.now() / 1000), + content: 'test content', + tags: [], + sig: 'test-sig', + }; + + return { + id: `source-${eventId}`, + oer_id: null, + oer: null, + source_name: SOURCE_NAME_NOSTR, + source_identifier: `event:${eventId}`, + source_data: eventData as unknown as Record, + status: 'pending', + source_uri: 'wss://relay.example.com', + source_timestamp: eventData.created_at, + source_record_type: String(kind), + created_at: new Date(), + updated_at: new Date(), + ...overrides, + }; +} + describe('EventDeletionService', () => { let service: EventDeletionService; - let nostrEventRepository: Repository; let oerRepository: Repository; + let oerSourceRepository: Repository; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ EventDeletionService, { - provide: getRepositoryToken(NostrEvent), + provide: getRepositoryToken(OpenEducationalResource), useValue: { - findOne: jest.fn(), - delete: jest.fn(), + update: jest.fn(), + createQueryBuilder: jest.fn(() => ({ + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + whereInIds: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 0 }), + })), }, }, { - provide: getRepositoryToken(OpenEducationalResource), + provide: getRepositoryToken(OerSource), useValue: { - update: jest.fn(), + findOne: jest.fn(), + find: jest.fn().mockResolvedValue([]), + delete: jest.fn(), + count: jest.fn().mockResolvedValue(0), }, }, ], @@ -55,12 +113,12 @@ describe('EventDeletionService', () => { .compile(); service = module.get(EventDeletionService); - nostrEventRepository = module.get>( - getRepositoryToken(NostrEvent), - ); oerRepository = module.get>( getRepositoryToken(OpenEducationalResource), ); + oerSourceRepository = module.get>( + getRepositoryToken(OerSource), + ); }); describe('extractEventReferences', () => { @@ -104,15 +162,19 @@ describe('EventDeletionService', () => { tags: [['e', 'event1']], }); - const referencedEvent = NostrEventFactory.create({ + const eventData: NostrEventData = { id: 'event1', kind: EVENT_AMB_KIND, pubkey: 'pubkey1', - }); + created_at: Math.floor(Date.now() / 1000), + content: '', + tags: [], + sig: 'test-sig', + }; const isValid = ( service as unknown as EventDeletionServiceWithPrivate - ).validateDeletionRequest(deleteEvent, referencedEvent); + ).validateDeletionRequest(deleteEvent, eventData); expect(isValid).toBe(true); }); @@ -124,15 +186,19 @@ describe('EventDeletionService', () => { tags: [['e', 'event1']], }); - const referencedEvent = NostrEventFactory.create({ + const eventData: NostrEventData = { id: 'event1', kind: EVENT_AMB_KIND, pubkey: 'pubkey2', - }); + created_at: Math.floor(Date.now() / 1000), + content: '', + tags: [], + sig: 'test-sig', + }; const isValid = ( service as unknown as EventDeletionServiceWithPrivate - ).validateDeletionRequest(deleteEvent, referencedEvent); + ).validateDeletionRequest(deleteEvent, eventData); expect(isValid).toBe(false); }); }); @@ -147,7 +213,7 @@ describe('EventDeletionService', () => { await service.processDeleteEvent(deleteEvent); - expect(nostrEventRepository.findOne).not.toHaveBeenCalled(); + expect(oerSourceRepository.findOne).not.toHaveBeenCalled(); }); it('should skip deletion when referenced event not found', async () => { @@ -157,14 +223,17 @@ describe('EventDeletionService', () => { tags: [['e', 'event1']], }); - jest.spyOn(nostrEventRepository, 'findOne').mockResolvedValue(null); + jest.spyOn(oerSourceRepository, 'findOne').mockResolvedValue(null); await service.processDeleteEvent(deleteEvent); - expect(nostrEventRepository.findOne).toHaveBeenCalledWith({ - where: { id: 'event1' }, + expect(oerSourceRepository.findOne).toHaveBeenCalledWith({ + where: { + source_name: SOURCE_NAME_NOSTR, + source_identifier: 'event:event1', + }, }); - expect(nostrEventRepository.delete).not.toHaveBeenCalled(); + expect(oerSourceRepository.delete).not.toHaveBeenCalled(); }); it('should skip deletion when pubkeys do not match', async () => { @@ -175,87 +244,134 @@ describe('EventDeletionService', () => { tags: [['e', 'event1']], }); - const referencedEvent = NostrEventFactory.create({ - id: 'event1', - kind: EVENT_AMB_KIND, - pubkey: 'pubkey2', - }); + const mockSource = createMockOerSource( + 'event1', + EVENT_AMB_KIND, + 'pubkey2', + ); - jest - .spyOn(nostrEventRepository, 'findOne') - .mockResolvedValue(referencedEvent); + jest.spyOn(oerSourceRepository, 'findOne').mockResolvedValue(mockSource); await service.processDeleteEvent(deleteEvent); - expect(nostrEventRepository.findOne).toHaveBeenCalled(); - expect(nostrEventRepository.delete).not.toHaveBeenCalled(); + expect(oerSourceRepository.findOne).toHaveBeenCalled(); + expect(oerSourceRepository.delete).not.toHaveBeenCalled(); }); }); describe('deleteEventAndCascade', () => { - it('should delete event and rely on database CASCADE for AMB events', async () => { + it('should delete event source for AMB events', async () => { + const mockSource = createMockOerSource( + 'event1', + EVENT_AMB_KIND, + 'pubkey1', + { oer_id: 'oer1' }, + ); + + jest.spyOn(oerSourceRepository, 'findOne').mockResolvedValue(mockSource); jest - .spyOn(nostrEventRepository, 'delete') + .spyOn(oerSourceRepository, 'delete') + .mockResolvedValue({ affected: 1, raw: {} }); + // Mock count for cascade check - return 0 remaining sources to trigger OER deletion + jest.spyOn(oerSourceRepository, 'count').mockResolvedValue(0); + (oerRepository as unknown as { delete: jest.Mock }).delete = jest + .fn() .mockResolvedValue({ affected: 1, raw: {} }); await service.deleteEventAndCascade('event1', EVENT_AMB_KIND); - expect(nostrEventRepository.delete).toHaveBeenCalledWith({ - id: 'event1', + expect(oerSourceRepository.delete).toHaveBeenCalledWith({ + id: mockSource.id, }); }); - it('should nullify file metadata and delete File event', async () => { + it('should nullify file metadata and delete File event source', async () => { + const mockFileSource = createMockOerSource( + 'file1', + EVENT_FILE_KIND, + 'pubkey1', + { oer_id: 'oer1' }, + ); + + // Mock OerSource repository to return sources for this file event (for nullifyFileMetadataForEvent) + jest.spyOn(oerSourceRepository, 'find').mockResolvedValue([ + createMockOerSource('file1', EVENT_FILE_KIND, 'pubkey1', { + oer_id: 'oer1', + }), + createMockOerSource('file1', EVENT_FILE_KIND, 'pubkey1', { + oer_id: 'oer2', + }), + ]); + + // Mock findOne for deleteEventAndCascade jest - .spyOn(oerRepository, 'update') - .mockResolvedValue({ affected: 2, raw: {}, generatedMaps: [] }); + .spyOn(oerSourceRepository, 'findOne') + .mockResolvedValue(mockFileSource); + jest - .spyOn(nostrEventRepository, 'delete') + .spyOn(oerSourceRepository, 'delete') .mockResolvedValue({ affected: 1, raw: {} }); await service.deleteEventAndCascade('file1', EVENT_FILE_KIND); - expect(oerRepository.update).toHaveBeenCalledWith( - { event_file_id: 'file1' }, - { - file_mime_type: null, - file_size: null, - file_dim: null, - file_alt: null, + // Verify OerSource find was called with correct filter (for file metadata nullification) + expect(oerSourceRepository.find).toHaveBeenCalledWith({ + where: { + source_name: SOURCE_NAME_NOSTR, + source_identifier: 'event:file1', }, - ); - expect(nostrEventRepository.delete).toHaveBeenCalledWith({ - id: 'file1', + }); + + // Verify query builder was used to update OER records + expect(oerRepository.createQueryBuilder).toHaveBeenCalled(); + + expect(oerSourceRepository.delete).toHaveBeenCalledWith({ + id: mockFileSource.id, }); }); - it('should delete event for other event kinds', async () => { + it('should delete event source for other event kinds', async () => { + const mockSource = createMockOerSource('event1', 1, 'pubkey1'); // Kind 1 (text note) + + jest.spyOn(oerSourceRepository, 'findOne').mockResolvedValue(mockSource); jest - .spyOn(nostrEventRepository, 'delete') + .spyOn(oerSourceRepository, 'delete') .mockResolvedValue({ affected: 1, raw: {} }); - await service.deleteEventAndCascade('event1', 1); // Kind 1 (text note) + await service.deleteEventAndCascade('event1', 1); - expect(nostrEventRepository.delete).toHaveBeenCalledWith({ - id: 'event1', + expect(oerSourceRepository.delete).toHaveBeenCalledWith({ + id: mockSource.id, }); }); it('should handle deletion of non-existent event', async () => { - jest - .spyOn(nostrEventRepository, 'delete') - .mockResolvedValue({ affected: 0, raw: {} }); + // Mock findOne to return null (source doesn't exist) + jest.spyOn(oerSourceRepository, 'findOne').mockResolvedValue(null); await service.deleteEventAndCascade('nonexistent', EVENT_AMB_KIND); - expect(nostrEventRepository.delete).toHaveBeenCalledWith({ - id: 'nonexistent', + // Should call findOne to look for the source + expect(oerSourceRepository.findOne).toHaveBeenCalledWith({ + where: { + source_name: SOURCE_NAME_NOSTR, + source_identifier: 'event:nonexistent', + }, }); + // Should NOT call delete since source wasn't found + expect(oerSourceRepository.delete).not.toHaveBeenCalled(); }); it('should throw error on database failure', async () => { + const mockSource = createMockOerSource( + 'event1', + EVENT_AMB_KIND, + 'pubkey1', + ); + + jest.spyOn(oerSourceRepository, 'findOne').mockResolvedValue(mockSource); jest - .spyOn(nostrEventRepository, 'delete') + .spyOn(oerSourceRepository, 'delete') .mockRejectedValue(new Error('Database error')); await expect( diff --git a/src/nostr/__tests__/nostr-client.service.spec.ts b/src/nostr/__tests__/nostr-client.service.spec.ts index a25b22c..273a22f 100644 --- a/src/nostr/__tests__/nostr-client.service.spec.ts +++ b/src/nostr/__tests__/nostr-client.service.spec.ts @@ -35,9 +35,9 @@ describe('NostrClientService', () => { findEvents: jest.fn(), findUnprocessedOerEvents: jest.fn().mockResolvedValue([]), countEvents: jest.fn(), - countEventsByKind: jest.fn(), - getLatestEventTimestamp: jest.fn().mockResolvedValue(null), - getLatestEventTimestampsByRelay: jest + countEventsByRecordType: jest.fn(), + getLatestTimestamp: jest.fn().mockResolvedValue(null), + getLatestTimestampsByRelay: jest .fn() .mockResolvedValue(new Map([['ws://localhost:10547', null]])), }; @@ -377,7 +377,7 @@ describe('NostrClientService', () => { it('should query database for per-relay timestamps on startup', async () => { // Clear previous calls and mock the database jest.clearAllMocks(); - mockDatabaseService.getLatestEventTimestampsByRelay.mockResolvedValueOnce( + mockDatabaseService.getLatestTimestampsByRelay.mockResolvedValueOnce( new Map([['ws://localhost:10547', null]]), ); @@ -386,14 +386,14 @@ describe('NostrClientService', () => { // Verify that the database was queried for the latest timestamp per relay expect( - mockDatabaseService.getLatestEventTimestampsByRelay, - ).toHaveBeenCalledWith(['ws://localhost:10547'], [30142, 1063, 5]); + mockDatabaseService.getLatestTimestampsByRelay, + ).toHaveBeenCalledWith(['ws://localhost:10547'], ['30142', '1063', '5']); }); it('should initialize connections with per-relay database timestamps on startup', async () => { // Mock database to return a specific timestamp for a relay const mockTimestamp = 1234567890; - mockDatabaseService.getLatestEventTimestampsByRelay.mockResolvedValueOnce( + mockDatabaseService.getLatestTimestampsByRelay.mockResolvedValueOnce( new Map([['ws://localhost:10547', mockTimestamp]]), ); diff --git a/src/nostr/__tests__/nostr-event-database.service.spec.ts b/src/nostr/__tests__/nostr-event-database.service.spec.ts index 9fe46c4..1e0615a 100644 --- a/src/nostr/__tests__/nostr-event-database.service.spec.ts +++ b/src/nostr/__tests__/nostr-event-database.service.spec.ts @@ -1,20 +1,45 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository, SelectQueryBuilder } from 'typeorm'; +import { Repository } from 'typeorm'; import { NostrEventDatabaseService } from '../services/nostr-event-database.service'; -import { NostrEvent } from '../entities/nostr-event.entity'; -import type { Event } from 'nostr-tools/core'; +import { OerSource } from '../../oer/entities/oer-source.entity'; import { EVENT_AMB_KIND } from '../constants/event-kinds.constants'; -import { EventFactory, NostrEventFactory } from '../../../test/fixtures'; - -type MockQueryBuilder = Pick< - SelectQueryBuilder, - 'leftJoin' | 'where' | 'andWhere' | 'getMany' | 'select' | 'getRawOne' ->; +import { EventFactory } from '../../../test/fixtures'; +import { SOURCE_NAME_NOSTR } from '../../oer/constants'; + +/** + * Creates a mock OerSource for testing. + */ +function createMockOerSource(overrides: Partial = {}): OerSource { + const defaults: OerSource = { + id: 'test-source-id', + oer_id: null, + oer: null, + source_name: SOURCE_NAME_NOSTR, + source_identifier: 'event:test-event-id', + source_data: { + id: 'test-event-id', + kind: 1, + pubkey: 'test-pubkey', + created_at: Math.floor(Date.now() / 1000), + content: 'test content', + tags: [], + sig: 'test-sig', + }, + status: 'pending', + source_uri: 'wss://relay.example.com', + source_timestamp: Math.floor(Date.now() / 1000), + source_record_type: '1', + created_at: new Date(), + updated_at: new Date(), + }; + + return { ...defaults, ...overrides }; +} describe('NostrEventDatabaseService', () => { let service: NostrEventDatabaseService; - let mockRepository: jest.Mocked>; + let mockRepository: jest.Mocked>; beforeEach(async () => { mockRepository = { @@ -25,13 +50,14 @@ describe('NostrEventDatabaseService', () => { find: jest.fn(), count: jest.fn(), createQueryBuilder: jest.fn(), - } as unknown as jest.Mocked>; + update: jest.fn(), + } as unknown as jest.Mocked>; const module: TestingModule = await Test.createTestingModule({ providers: [ NostrEventDatabaseService, { - provide: getRepositoryToken(NostrEvent), + provide: getRepositoryToken(OerSource), useValue: mockRepository, }, ], @@ -56,10 +82,16 @@ describe('NostrEventDatabaseService', () => { describe('saveEvent', () => { it('should successfully save a valid event', async () => { const mockEvent = EventFactory.create(); - const mockNostrEvent = NostrEventFactory.create(); + const mockOerSource = createMockOerSource({ + source_identifier: `event:${mockEvent.id}`, + source_data: mockEvent as unknown as Record, + source_record_type: String(mockEvent.kind), + source_timestamp: mockEvent.created_at, + }); - mockRepository.create.mockReturnValue(mockNostrEvent); - mockRepository.save.mockResolvedValue(mockNostrEvent); + mockRepository.findOne.mockResolvedValue(null); // No existing source + mockRepository.create.mockReturnValue(mockOerSource); + mockRepository.save.mockResolvedValue(mockOerSource); const result = await service.saveEvent( mockEvent, @@ -68,19 +100,20 @@ describe('NostrEventDatabaseService', () => { expect(result.success).toBe(true); if (result.success) { - expect(result.event).toEqual(mockNostrEvent); + expect(result.source).toEqual(mockOerSource); } expect(mockRepository.create).toHaveBeenCalled(); - expect(mockRepository.save).toHaveBeenCalledWith(mockNostrEvent); + expect(mockRepository.save).toHaveBeenCalledWith(mockOerSource); }); - it('should return duplicate result when event ID already exists', async () => { + it('should return duplicate result when event already exists', async () => { const mockEvent = EventFactory.create(); - const mockNostrEvent = NostrEventFactory.create(); + const existingSource = createMockOerSource({ + source_identifier: `event:${mockEvent.id}`, + }); - mockRepository.create.mockReturnValue(mockNostrEvent); - mockRepository.save.mockRejectedValue({ code: '23505' }); + mockRepository.findOne.mockResolvedValue(existingSource); const result = await service.saveEvent( mockEvent, @@ -92,16 +125,16 @@ describe('NostrEventDatabaseService', () => { expect(result.reason).toBe('duplicate'); } - expect(mockRepository.save).toHaveBeenCalled(); + expect(mockRepository.save).not.toHaveBeenCalled(); }); - it('should return error result when database operation fails', async () => { + it('should return duplicate result when duplicate key error occurs', async () => { const mockEvent = EventFactory.create(); - const mockNostrEvent = NostrEventFactory.create(); - const mockError = new Error('Database connection failed'); + const mockOerSource = createMockOerSource(); - mockRepository.create.mockReturnValue(mockNostrEvent); - mockRepository.save.mockRejectedValue(mockError); + mockRepository.findOne.mockResolvedValue(null); + mockRepository.create.mockReturnValue(mockOerSource); + mockRepository.save.mockRejectedValue({ code: '23505' }); const result = await service.saveEvent( mockEvent, @@ -109,41 +142,41 @@ describe('NostrEventDatabaseService', () => { ); expect(result.success).toBe(false); - if (!result.success && result.reason === 'error') { - expect(result.reason).toBe('error'); - expect(result.error).toBe(mockError); + if (!result.success) { + expect(result.reason).toBe('duplicate'); } }); - it('should handle events with different kinds', async () => { - const mockEvent = EventFactory.create({ kind: EVENT_AMB_KIND }); - const mockNostrEvent = NostrEventFactory.create({ kind: EVENT_AMB_KIND }); + it('should return error result when database operation fails', async () => { + const mockEvent = EventFactory.create(); + const mockOerSource = createMockOerSource(); + const mockError = new Error('Database connection failed'); - mockRepository.create.mockReturnValue(mockNostrEvent); - mockRepository.save.mockResolvedValue(mockNostrEvent); + mockRepository.findOne.mockResolvedValue(null); + mockRepository.create.mockReturnValue(mockOerSource); + mockRepository.save.mockRejectedValue(mockError); const result = await service.saveEvent( mockEvent, 'wss://relay.example.com', ); - expect(result.success).toBe(true); - if (result.success) { - expect(result.event.kind).toBe(EVENT_AMB_KIND); + expect(result.success).toBe(false); + if (!result.success && result.reason === 'error') { + expect(result.reason).toBe('error'); + expect(result.error).toBe(mockError); } }); - it('should handle events with complex tag arrays', async () => { - const complexTags = [ - ['e', 'referenced-event-id'], - ['p', 'referenced-pubkey'], - ['description', 'Multi-word description'], - ]; - const mockEvent = EventFactory.create({ tags: complexTags }); - const mockNostrEvent = NostrEventFactory.create({ tags: complexTags }); + it('should handle events with different kinds', async () => { + const mockEvent = EventFactory.create({ kind: EVENT_AMB_KIND }); + const mockOerSource = createMockOerSource({ + source_record_type: String(EVENT_AMB_KIND), + }); - mockRepository.create.mockReturnValue(mockNostrEvent); - mockRepository.save.mockResolvedValue(mockNostrEvent); + mockRepository.findOne.mockResolvedValue(null); + mockRepository.create.mockReturnValue(mockOerSource); + mockRepository.save.mockResolvedValue(mockOerSource); const result = await service.saveEvent( mockEvent, @@ -152,21 +185,24 @@ describe('NostrEventDatabaseService', () => { expect(result.success).toBe(true); if (result.success) { - expect(result.event.tags).toEqual(complexTags); + expect(result.source.source_record_type).toBe(String(EVENT_AMB_KIND)); } }); }); describe('findEventById', () => { - it('should find an event by ID', async () => { - const mockNostrEvent = NostrEventFactory.create(); - mockRepository.findOne.mockResolvedValue(mockNostrEvent); + it('should find an event source by event ID', async () => { + const mockOerSource = createMockOerSource(); + mockRepository.findOne.mockResolvedValue(mockOerSource); const result = await service.findEventById('test-event-id'); - expect(result).toEqual(mockNostrEvent); + expect(result).toEqual(mockOerSource); expect(mockRepository.findOne).toHaveBeenCalledWith({ - where: { id: 'test-event-id' }, + where: { + source_name: SOURCE_NAME_NOSTR, + source_identifier: 'event:test-event-id', + }, }); }); @@ -180,113 +216,104 @@ describe('NostrEventDatabaseService', () => { }); describe('findEvents', () => { - it('should find events by kind', async () => { - const mockEvents = [ - NostrEventFactory.create({ id: 'event-1', kind: 1 }), - NostrEventFactory.create({ id: 'event-2', kind: 1 }), + it('should find events by source_record_type', async () => { + const mockSources = [ + createMockOerSource({ id: 'source-1', source_record_type: '1' }), + createMockOerSource({ id: 'source-2', source_record_type: '1' }), ]; - mockRepository.find.mockResolvedValue(mockEvents); + mockRepository.find.mockResolvedValue(mockSources); - const result = await service.findEvents({ kind: 1 }); + const result = await service.findEvents({ source_record_type: '1' }); - expect(result).toEqual(mockEvents); + expect(result).toEqual(mockSources); expect(mockRepository.find).toHaveBeenCalledWith({ - where: { kind: 1 }, + where: { source_name: SOURCE_NAME_NOSTR, source_record_type: '1' }, }); }); - it('should find events by pubkey', async () => { - const mockEvents = [NostrEventFactory.create({ pubkey: 'test-pubkey' })]; - mockRepository.find.mockResolvedValue(mockEvents); + it('should find events by source_uri', async () => { + const mockSources = [ + createMockOerSource({ source_uri: 'wss://relay.example.com' }), + ]; + mockRepository.find.mockResolvedValue(mockSources); - const result = await service.findEvents({ pubkey: 'test-pubkey' }); + const result = await service.findEvents({ + source_uri: 'wss://relay.example.com', + }); - expect(result).toEqual(mockEvents); + expect(result).toEqual(mockSources); expect(mockRepository.find).toHaveBeenCalledWith({ - where: { pubkey: 'test-pubkey' }, + where: { + source_name: SOURCE_NAME_NOSTR, + source_uri: 'wss://relay.example.com', + }, }); }); it('should find events by multiple criteria', async () => { - const mockEvents = [ - NostrEventFactory.create({ - kind: EVENT_AMB_KIND, - pubkey: 'test-pubkey', + const mockSources = [ + createMockOerSource({ + source_record_type: String(EVENT_AMB_KIND), + source_uri: 'wss://relay.example.com', }), ]; - mockRepository.find.mockResolvedValue(mockEvents); + mockRepository.find.mockResolvedValue(mockSources); const result = await service.findEvents({ - kind: EVENT_AMB_KIND, - pubkey: 'test-pubkey', + source_record_type: String(EVENT_AMB_KIND), + source_uri: 'wss://relay.example.com', }); - expect(result).toEqual(mockEvents); + expect(result).toEqual(mockSources); expect(mockRepository.find).toHaveBeenCalledWith({ - where: { kind: EVENT_AMB_KIND, pubkey: 'test-pubkey' }, + where: { + source_name: SOURCE_NAME_NOSTR, + source_record_type: String(EVENT_AMB_KIND), + source_uri: 'wss://relay.example.com', + }, }); }); it('should return empty array when no events match', async () => { mockRepository.find.mockResolvedValue([]); - const result = await service.findEvents({ kind: 999 }); + const result = await service.findEvents({ source_record_type: '999' }); expect(result).toEqual([]); }); }); describe('findUnprocessedOerEvents', () => { - it('should find kind 30142 (AMB) events without OER records', async () => { - const mockEvents = [ - NostrEventFactory.create({ id: 'oer-event-1', kind: EVENT_AMB_KIND }), - NostrEventFactory.create({ id: 'oer-event-2', kind: EVENT_AMB_KIND }), + it('should find pending kind 30142 (AMB) events', async () => { + const mockSources = [ + createMockOerSource({ + id: 'source-1', + source_record_type: String(EVENT_AMB_KIND), + status: 'pending', + }), + createMockOerSource({ + id: 'source-2', + source_record_type: String(EVENT_AMB_KIND), + status: 'pending', + }), ]; - const mockQueryBuilder: MockQueryBuilder = { - leftJoin: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getMany: jest.fn().mockResolvedValue(mockEvents), - select: jest.fn().mockReturnThis(), - getRawOne: jest.fn(), - }; - - mockRepository.createQueryBuilder.mockReturnValue( - mockQueryBuilder as unknown as SelectQueryBuilder, - ); + mockRepository.find.mockResolvedValue(mockSources); const result = await service.findUnprocessedOerEvents(); - expect(result).toEqual(mockEvents); - expect(mockRepository.createQueryBuilder).toHaveBeenCalledWith('event'); - expect(mockQueryBuilder.leftJoin).toHaveBeenCalledWith( - 'open_educational_resources', - 'oer', - 'oer.event_amb_id = event.id', - ); - expect(mockQueryBuilder.where).toHaveBeenCalledWith( - 'event.kind = :kind', - { - kind: EVENT_AMB_KIND, + expect(result).toEqual(mockSources); + expect(mockRepository.find).toHaveBeenCalledWith({ + where: { + source_name: SOURCE_NAME_NOSTR, + source_record_type: String(EVENT_AMB_KIND), + status: 'pending', }, - ); - expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('oer.id IS NULL'); + }); }); it('should return empty array when all OER events are processed', async () => { - const mockQueryBuilder: MockQueryBuilder = { - leftJoin: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getMany: jest.fn().mockResolvedValue([]), - select: jest.fn().mockReturnThis(), - getRawOne: jest.fn(), - }; - - mockRepository.createQueryBuilder.mockReturnValue( - mockQueryBuilder as unknown as SelectQueryBuilder, - ); + mockRepository.find.mockResolvedValue([]); const result = await service.findUnprocessedOerEvents(); @@ -295,18 +322,7 @@ describe('NostrEventDatabaseService', () => { it('should throw error when query fails', async () => { const mockError = new Error('Query failed'); - const mockQueryBuilder: MockQueryBuilder = { - leftJoin: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getMany: jest.fn().mockRejectedValue(mockError), - select: jest.fn().mockReturnThis(), - getRawOne: jest.fn(), - }; - - mockRepository.createQueryBuilder.mockReturnValue( - mockQueryBuilder as unknown as SelectQueryBuilder, - ); + mockRepository.find.mockRejectedValue(mockError); await expect(service.findUnprocessedOerEvents()).rejects.toThrow( 'Query failed', @@ -315,13 +331,15 @@ describe('NostrEventDatabaseService', () => { }); describe('countEvents', () => { - it('should return total event count', async () => { + it('should return total event count for nostr sources', async () => { mockRepository.count.mockResolvedValue(42); const result = await service.countEvents(); expect(result).toBe(42); - expect(mockRepository.count).toHaveBeenCalledWith(); + expect(mockRepository.count).toHaveBeenCalledWith({ + where: { source_name: SOURCE_NAME_NOSTR }, + }); }); it('should return 0 when no events exist', async () => { @@ -333,20 +351,22 @@ describe('NostrEventDatabaseService', () => { }); }); - describe('countEventsByKind', () => { - it('should count events by specific kind', async () => { + describe('countEventsByRecordType', () => { + it('should count events by specific record type', async () => { mockRepository.count.mockResolvedValue(10); - const result = await service.countEventsByKind(1); + const result = await service.countEventsByRecordType('1'); expect(result).toBe(10); - expect(mockRepository.count).toHaveBeenCalledWith({ where: { kind: 1 } }); + expect(mockRepository.count).toHaveBeenCalledWith({ + where: { source_name: SOURCE_NAME_NOSTR, source_record_type: '1' }, + }); }); - it('should return 0 for kind with no events', async () => { + it('should return 0 for record type with no events', async () => { mockRepository.count.mockResolvedValue(0); - const result = await service.countEventsByKind(999); + const result = await service.countEventsByRecordType('999'); expect(result).toBe(0); }); @@ -354,22 +374,65 @@ describe('NostrEventDatabaseService', () => { it('should count OER events (kind 30142 AMB)', async () => { mockRepository.count.mockResolvedValue(25); - const result = await service.countEventsByKind(EVENT_AMB_KIND); + const result = await service.countEventsByRecordType( + String(EVENT_AMB_KIND), + ); expect(result).toBe(25); expect(mockRepository.count).toHaveBeenCalledWith({ - where: { kind: EVENT_AMB_KIND }, + where: { + source_name: SOURCE_NAME_NOSTR, + source_record_type: String(EVENT_AMB_KIND), + }, }); }); }); + describe('markEventProcessed', () => { + it('should update event status to processed with OER ID', async () => { + mockRepository.update.mockResolvedValue({ affected: 1 } as never); + + await service.markEventProcessed('event-123', 'oer-456'); + + expect(mockRepository.update).toHaveBeenCalledWith( + { + source_name: SOURCE_NAME_NOSTR, + source_identifier: 'event:event-123', + }, + { + status: 'processed', + oer_id: 'oer-456', + }, + ); + }); + }); + + describe('markEventFailed', () => { + it('should update event status to failed', async () => { + mockRepository.update.mockResolvedValue({ affected: 1 } as never); + + await service.markEventFailed('event-123'); + + expect(mockRepository.update).toHaveBeenCalledWith( + { + source_name: SOURCE_NAME_NOSTR, + source_identifier: 'event:event-123', + }, + { + status: 'failed', + }, + ); + }); + }); + describe('error handling', () => { it('should handle network errors gracefully', async () => { const mockEvent = EventFactory.create(); - const mockNostrEvent = NostrEventFactory.create(); + const mockOerSource = createMockOerSource(); const networkError = new Error('ECONNREFUSED'); - mockRepository.create.mockReturnValue(mockNostrEvent); + mockRepository.findOne.mockResolvedValue(null); + mockRepository.create.mockReturnValue(mockOerSource); mockRepository.save.mockRejectedValue(networkError); const result = await service.saveEvent( @@ -386,11 +449,12 @@ describe('NostrEventDatabaseService', () => { it('should distinguish between duplicate and other errors', async () => { const mockEvent1 = EventFactory.create({ id: 'event-1' }); const mockEvent2 = EventFactory.create({ id: 'event-2' }); - const mockNostrEvent1 = NostrEventFactory.create({ id: 'event-1' }); - const mockNostrEvent2 = NostrEventFactory.create({ id: 'event-2' }); + const mockOerSource1 = createMockOerSource({ id: 'source-1' }); + const mockOerSource2 = createMockOerSource({ id: 'source-2' }); // First call - simulate duplicate key error - mockRepository.create.mockReturnValueOnce(mockNostrEvent1); + mockRepository.findOne.mockResolvedValueOnce(null); + mockRepository.create.mockReturnValueOnce(mockOerSource1); mockRepository.save.mockRejectedValueOnce({ code: '23505' }); const duplicateResult = await service.saveEvent( @@ -403,7 +467,8 @@ describe('NostrEventDatabaseService', () => { } // Second call - simulate generic error - mockRepository.create.mockReturnValueOnce(mockNostrEvent2); + mockRepository.findOne.mockResolvedValueOnce(null); + mockRepository.create.mockReturnValueOnce(mockOerSource2); mockRepository.save.mockRejectedValueOnce(new Error('Generic error')); const errorResult = await service.saveEvent( diff --git a/src/nostr/entities/nostr-event.entity.ts b/src/nostr/entities/nostr-event.entity.ts deleted file mode 100644 index 3e47d05..0000000 --- a/src/nostr/entities/nostr-event.entity.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { - Entity, - Column, - PrimaryColumn, - CreateDateColumn, - Index, -} from 'typeorm'; - -@Entity('nostr_events') -export class NostrEvent { - @PrimaryColumn({ length: 64 }) - id: string; - - @Column('int') - @Index() - kind: number; - - @Column({ length: 64 }) - @Index() - pubkey: string; - - @Column('bigint') - @Index() - created_at: number; - - @Column('text') - content: string; - - @Column('jsonb') - tags: string[][]; - - @Column('jsonb') - raw_event: Record; - - @Column({ type: 'varchar', length: 512, nullable: true }) - @Index() - relay_url: string | null; - - @CreateDateColumn() - ingested_at: Date; -} diff --git a/src/nostr/nostr.module.ts b/src/nostr/nostr.module.ts index 5e74070..5fa96ce 100644 --- a/src/nostr/nostr.module.ts +++ b/src/nostr/nostr.module.ts @@ -1,6 +1,7 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { NostrEvent } from './entities/nostr-event.entity'; +import { OerSource } from '../oer/entities/oer-source.entity'; +import { OpenEducationalResource } from '../oer/entities/open-educational-resource.entity'; import { NostrClientService } from './services/nostr-client.service'; import { NostrEventDatabaseService } from './services/nostr-event-database.service'; import { EventDeletionService } from './services/event-deletion.service'; @@ -8,7 +9,7 @@ import { OerModule } from '../oer/oer.module'; @Module({ imports: [ - TypeOrmModule.forFeature([NostrEvent]), + TypeOrmModule.forFeature([OerSource, OpenEducationalResource]), forwardRef(() => OerModule), ], providers: [ diff --git a/src/nostr/services/event-deletion.service.ts b/src/nostr/services/event-deletion.service.ts index ea240dd..86ea7cb 100644 --- a/src/nostr/services/event-deletion.service.ts +++ b/src/nostr/services/event-deletion.service.ts @@ -2,25 +2,45 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import type { Event } from 'nostr-tools/core'; -import { NostrEvent } from '../entities/nostr-event.entity'; import { OpenEducationalResource } from '../../oer/entities/open-educational-resource.entity'; -import { EVENT_FILE_KIND } from '../constants/event-kinds.constants'; +import { OerSource } from '../../oer/entities/oer-source.entity'; +import { + EVENT_FILE_KIND, + EVENT_AMB_KIND, +} from '../constants/event-kinds.constants'; +import { + SOURCE_NAME_NOSTR, + createNostrSourceIdentifier, +} from '../../oer/constants'; + +/** + * Represents the structure of a Nostr event stored in source_data. + */ +interface NostrEventData { + id: string; + kind: number; + pubkey: string; + created_at: number; + content: string; + tags: string[][]; + sig: string; +} /** * Service for handling NIP-09 event deletion requests. - * Validates deletion requests and relies on database constraints for cascading: - * - AMB events (kind 30142): ON DELETE CASCADE removes associated OER records - * - File events (kind 1063): Nullifies file metadata fields, then ON DELETE SET NULL removes reference + * Validates deletion requests and handles cascading deletions via OerSource. + * - AMB events (kind 30142): Deleting the source cascades to remove associated OER records + * - File events (kind 1063): Nullifies file metadata fields before deletion */ @Injectable() export class EventDeletionService { private readonly logger = new Logger(EventDeletionService.name); constructor( - @InjectRepository(NostrEvent) - private readonly nostrEventRepository: Repository, @InjectRepository(OpenEducationalResource) private readonly oerRepository: Repository, + @InjectRepository(OerSource) + private readonly oerSourceRepository: Repository, ) {} /** @@ -60,28 +80,41 @@ export class EventDeletionService { eventId: string, ): Promise { try { - // Find the referenced event - const referencedEvent = await this.nostrEventRepository.findOne({ - where: { id: eventId }, + // Find the referenced event source + const sourceIdentifier = createNostrSourceIdentifier(eventId); + const referencedSource = await this.oerSourceRepository.findOne({ + where: { + source_name: SOURCE_NAME_NOSTR, + source_identifier: sourceIdentifier, + }, }); - if (!referencedEvent) { + if (!referencedSource) { this.logger.debug( `Referenced event ${eventId} not found in database, skipping deletion`, ); return; } + // Extract the original event data from source_data + const eventData = + referencedSource.source_data as unknown as NostrEventData; + // Validate deletion request per NIP-09 - if (!this.validateDeletionRequest(deleteEvent, referencedEvent)) { + if (!this.validateDeletionRequest(deleteEvent, eventData)) { this.logger.warn( - `Deletion validation failed for event ${eventId}: pubkey mismatch (delete pubkey: ${deleteEvent.pubkey}, event pubkey: ${referencedEvent.pubkey})`, + `Deletion validation failed for event ${eventId}: pubkey mismatch (delete pubkey: ${deleteEvent.pubkey}, event pubkey: ${eventData.pubkey})`, ); return; } + // Get the event kind from source_record_type + const eventKind = referencedSource.source_record_type + ? parseInt(referencedSource.source_record_type, 10) + : eventData.kind; + // Perform cascade deletion based on event kind - await this.deleteEventAndCascade(eventId, referencedEvent.kind); + await this.deleteEventAndCascade(eventId, eventKind); } catch (error) { this.logger.error( `Failed to process deletion for event ${eventId}: ${error instanceof Error ? error.message : String(error)}`, @@ -96,22 +129,22 @@ export class EventDeletionService { * The pubkey of the deletion event must match the pubkey of the event being deleted. * * @param deleteEvent - The kind 5 deletion event - * @param referencedEvent - The event being referenced for deletion + * @param referencedEventData - The event data being referenced for deletion * @returns true if deletion is valid, false otherwise */ private validateDeletionRequest( deleteEvent: Event, - referencedEvent: NostrEvent, + referencedEventData: NostrEventData, ): boolean { - return deleteEvent.pubkey === referencedEvent.pubkey; + return deleteEvent.pubkey === referencedEventData.pubkey; } /** - * Deletes an event and handles cascading to dependent entities. + * Deletes an event source and handles cascading to dependent entities. * * Behavior by event kind: - * - AMB events (kind 30142): Database CASCADE deletes associated OER records - * - File events (kind 1063): Nullifies file metadata fields before deletion, then database SET NULL removes reference + * - AMB events (kind 30142): Deleting the OerSource may cascade to OER if it's the only source + * - File events (kind 1063): Nullifies file metadata fields before deletion * - Other events: Direct deletion * * @param eventId - The ID of the event to delete @@ -127,12 +160,37 @@ export class EventDeletionService { await this.nullifyFileMetadataForEvent(eventId); } - const result = await this.nostrEventRepository.delete({ id: eventId }); + const sourceIdentifier = createNostrSourceIdentifier(eventId); - if (result.affected && result.affected > 0) { - this.logger.log(`Deleted event ${eventId} (kind: ${eventKind})`); - } else { - this.logger.debug(`Event ${eventId} not found in database`); + // Find the source and its linked OER before deleting + const sourceToDelete = await this.oerSourceRepository.findOne({ + where: { + source_name: SOURCE_NAME_NOSTR, + source_identifier: sourceIdentifier, + }, + }); + + if (!sourceToDelete) { + this.logger.debug(`Event source ${eventId} not found in database`); + return; + } + + const oerId = sourceToDelete.oer_id; + + // Delete the source + await this.oerSourceRepository.delete({ id: sourceToDelete.id }); + this.logger.log(`Deleted event source ${eventId} (kind: ${eventKind})`); + + // For AMB events, check if the OER should be deleted (no more sources) + if (eventKind === EVENT_AMB_KIND && oerId) { + const remainingSources = await this.oerSourceRepository.count({ + where: { oer_id: oerId }, + }); + + if (remainingSources === 0) { + await this.oerRepository.delete({ id: oerId }); + this.logger.log(`Deleted OER ${oerId} (no remaining sources)`); + } } } catch (error) { this.logger.error( @@ -145,21 +203,47 @@ export class EventDeletionService { /** * Nullifies file metadata fields (file_*) in OER records that reference the given file event. + * Finds OERs via the oer_sources table and nullifies their file metadata. * * @param fileEventId - The ID of the file event being deleted */ private async nullifyFileMetadataForEvent( fileEventId: string, ): Promise { - const result = await this.oerRepository.update( - { event_file_id: fileEventId }, - { + // Find all OER sources that reference this file event + const sourceIdentifier = createNostrSourceIdentifier(fileEventId); + const sources = await this.oerSourceRepository.find({ + where: { + source_name: SOURCE_NAME_NOSTR, + source_identifier: sourceIdentifier, + }, + }); + + if (sources.length === 0) { + this.logger.debug(`No OER records found for file event ${fileEventId}`); + return; + } + + // Nullify file metadata for all affected OERs + const oerIds = sources + .map((source) => source.oer_id) + .filter((id): id is string => id !== null); + + if (oerIds.length === 0) { + return; + } + + const result = await this.oerRepository + .createQueryBuilder() + .update(OpenEducationalResource) + .set({ file_mime_type: null, file_size: null, file_dim: null, file_alt: null, - }, - ); + }) + .whereInIds(oerIds) + .execute(); if (result.affected && result.affected > 0) { this.logger.log( diff --git a/src/nostr/services/nostr-client.service.ts b/src/nostr/services/nostr-client.service.ts index d82a08c..b689b20 100644 --- a/src/nostr/services/nostr-client.service.ts +++ b/src/nostr/services/nostr-client.service.ts @@ -6,7 +6,7 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import type { Event } from 'nostr-tools/core'; -import { NostrEvent } from '../entities/nostr-event.entity'; +import { OerSource } from '../../oer/entities/oer-source.entity'; import { OerExtractionService } from '../../oer/services/oer-extraction.service'; import { RelayConfigParser } from '../utils/relay-config.parser'; import { EventValidator } from '../utils/event-validator'; @@ -62,10 +62,11 @@ export class NostrClientService implements OnModuleInit, OnModuleDestroy { private async connectToAllRelays() { // Query database for the latest event timestamp per relay to resume from const timestampsByRelay = - await this.databaseService.getLatestEventTimestampsByRelay( - this.relayUrls, - [EVENT_AMB_KIND, EVENT_FILE_KIND, EVENT_DELETE_KIND], - ); + await this.databaseService.getLatestTimestampsByRelay(this.relayUrls, [ + String(EVENT_AMB_KIND), + String(EVENT_FILE_KIND), + String(EVENT_DELETE_KIND), + ]); for (const url of this.relayUrls) { const latestTimestamp = timestampsByRelay.get(url); @@ -186,7 +187,7 @@ export class NostrClientService implements OnModuleInit, OnModuleDestroy { } } - await this.extractOerIfApplicable(result.event); + await this.extractOerIfApplicable(result.source); } private handleSaveFailure( @@ -233,18 +234,21 @@ export class NostrClientService implements OnModuleInit, OnModuleDestroy { } } - private async extractOerIfApplicable(nostrEvent: NostrEvent): Promise { + private async extractOerIfApplicable(oerSource: OerSource): Promise { // Extract OER data for kind 30142 (AMB) events (only after EOSE to ensure dependencies exist) if ( this.hasReceivedEose && - this.oerExtractionService.shouldExtractOer(nostrEvent.kind) + oerSource.source_record_type !== null && + this.oerExtractionService.shouldExtractOer( + parseInt(oerSource.source_record_type, 10), + ) ) { try { - await this.oerExtractionService.extractOerFromEvent(nostrEvent); + await this.oerExtractionService.extractOerFromSource(oerSource); } catch (oerError) { // Log OER extraction errors but don't fail the event ingestion this.logger.error( - `OER extraction failed for event ${nostrEvent.id}: ${DatabaseErrorClassifier.extractErrorMessage(oerError)}`, + `OER extraction failed for source ${oerSource.id}: ${DatabaseErrorClassifier.extractErrorMessage(oerError)}`, DatabaseErrorClassifier.extractStackTrace(oerError), ); } @@ -266,20 +270,20 @@ export class NostrClientService implements OnModuleInit, OnModuleDestroy { `Found ${ambEvents.length} kind ${EVENT_AMB_KIND} events without OER records`, ); - for (const event of ambEvents) { + for (const source of ambEvents) { try { - await this.oerExtractionService.extractOerFromEvent(event); + await this.oerExtractionService.extractOerFromSource(source); } catch (error) { // Duplicate key errors are expected and can be safely ignored if (DatabaseErrorClassifier.isDuplicateKeyError(error)) { this.logger.debug( - `OER already exists for event ${event.id}, skipping`, + `OER already exists for source ${source.id}, skipping`, ); continue; } this.logger.error( - `Failed to extract OER for historical event ${event.id}: ${DatabaseErrorClassifier.extractErrorMessage(error)}`, + `Failed to extract OER for historical source ${source.id}: ${DatabaseErrorClassifier.extractErrorMessage(error)}`, DatabaseErrorClassifier.extractStackTrace(error), ); } @@ -298,7 +302,7 @@ export class NostrClientService implements OnModuleInit, OnModuleDestroy { } /** - * Updates existing OER records that have event_file_id but missing file metadata. + * Updates existing OER records that have file event sources but missing file metadata. * This handles cases where OER was extracted before the file event arrived. */ private async backfillMissingFileMetadata() { @@ -346,22 +350,22 @@ export class NostrClientService implements OnModuleInit, OnModuleDestroy { // Find all kind 5 (deletion) events from this specific relay const deleteEvents = await this.databaseService.findEvents({ - kind: EVENT_DELETE_KIND, - relay_url: relayUrl, + source_record_type: String(EVENT_DELETE_KIND), + source_uri: relayUrl, }); this.logger.log( `Found ${deleteEvents.length} kind ${EVENT_DELETE_KIND} deletion events from ${relayUrl}`, ); - for (const deleteEvent of deleteEvents) { + for (const deleteSource of deleteEvents) { try { - // Convert NostrEvent entity to Event type expected by deletion service - const event = deleteEvent.raw_event as unknown as Event; + // Extract Event from source_data + const event = deleteSource.source_data as unknown as Event; await this.eventDeletionService.processDeleteEvent(event); } catch (error) { this.logger.error( - `Failed to process historical deletion event ${deleteEvent.id}: ${DatabaseErrorClassifier.extractErrorMessage(error)}`, + `Failed to process historical deletion source ${deleteSource.id}: ${DatabaseErrorClassifier.extractErrorMessage(error)}`, DatabaseErrorClassifier.extractStackTrace(error), ); } diff --git a/src/nostr/services/nostr-event-database.service.ts b/src/nostr/services/nostr-event-database.service.ts index 8d4c8e6..a6a4515 100644 --- a/src/nostr/services/nostr-event-database.service.ts +++ b/src/nostr/services/nostr-event-database.service.ts @@ -2,16 +2,21 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import type { Event } from 'nostr-tools/core'; -import { NostrEvent } from '../entities/nostr-event.entity'; +import { OerSource } from '../../oer/entities/oer-source.entity'; import { DatabaseErrorClassifier } from '../utils/database-error.classifier'; import { EVENT_AMB_KIND } from '../constants/event-kinds.constants'; +import { + SOURCE_NAME_NOSTR, + createNostrSourceIdentifier, + createNostrSourceUri, +} from '../../oer/constants'; /** * Result of a database save operation. * Provides type-safe handling of success/duplicate cases. */ export type SaveEventResult = - | { success: true; event: NostrEvent } + | { success: true; source: OerSource } | { success: false; reason: 'duplicate' } | { success: false; reason: 'error'; error: unknown }; @@ -19,27 +24,26 @@ export type SaveEventResult = * Criteria for finding Nostr events. */ export interface FindEventCriteria { - kind?: number; - pubkey?: string; - id?: string; - relay_url?: string; + source_record_type?: string; + source_identifier?: string; + source_uri?: string; } /** * Database service for Nostr event operations. - * Encapsulates all TypeORM repository interactions and database-specific logic. + * Uses oer_sources table for storage, enabling unified source tracking. */ @Injectable() export class NostrEventDatabaseService { private readonly logger = new Logger(NostrEventDatabaseService.name); constructor( - @InjectRepository(NostrEvent) - private readonly repository: Repository, + @InjectRepository(OerSource) + private readonly repository: Repository, ) {} /** - * Saves a Nostr event to the database. + * Saves a Nostr event to the database as a pending OerSource. * Returns a type-safe result indicating success, duplicate, or error. * * @param event - The Nostr event to persist @@ -48,22 +52,35 @@ export class NostrEventDatabaseService { */ async saveEvent(event: Event, relayUrl: string): Promise { try { - const eventData = { - id: event.id, - kind: event.kind, - pubkey: event.pubkey, - created_at: event.created_at, - content: event.content, - tags: event.tags, - raw_event: event as unknown as Record, - relay_url: relayUrl, + const sourceIdentifier = createNostrSourceIdentifier(event.id); + + // Check if this event already exists + const existing = await this.repository.findOne({ + where: { + source_name: SOURCE_NAME_NOSTR, + source_identifier: sourceIdentifier, + }, + }); + + if (existing) { + return { success: false, reason: 'duplicate' }; + } + + const sourceData = { + source_name: SOURCE_NAME_NOSTR, + source_identifier: sourceIdentifier, + source_data: event as unknown as Record, + source_uri: createNostrSourceUri(relayUrl), + source_timestamp: event.created_at, + source_record_type: String(event.kind), + status: 'pending' as const, + oer_id: null, // Pending events have no OER yet }; - // Create the entity and save it (insert or update) - const nostrEvent = this.repository.create(eventData); - const savedEvent = await this.repository.save(nostrEvent); + const oerSource = this.repository.create(sourceData); + const savedSource = await this.repository.save(oerSource); - return { success: true, event: savedEvent }; + return { success: true, source: savedSource }; } catch (error) { // Handle duplicate key constraint violations if (DatabaseErrorClassifier.isDuplicateKeyError(error)) { @@ -75,43 +92,60 @@ export class NostrEventDatabaseService { } /** - * Finds a single event by its ID. + * Finds a single event source by its event ID. * - * @param eventId - The event ID to search for - * @returns The NostrEvent if found, null otherwise + * @param eventId - The Nostr event ID to search for + * @returns The OerSource if found, null otherwise */ - async findEventById(eventId: string): Promise { - return this.repository.findOne({ where: { id: eventId } }); + async findEventById(eventId: string): Promise { + const sourceIdentifier = createNostrSourceIdentifier(eventId); + return this.repository.findOne({ + where: { + source_name: SOURCE_NAME_NOSTR, + source_identifier: sourceIdentifier, + }, + }); } /** - * Finds events matching the specified criteria. + * Finds event sources matching the specified criteria. * * @param criteria - Search criteria for events - * @returns Array of matching NostrEvents + * @returns Array of matching OerSources */ - async findEvents(criteria: FindEventCriteria): Promise { - return this.repository.find({ where: criteria }); + async findEvents(criteria: FindEventCriteria): Promise { + const where: Record = { + source_name: SOURCE_NAME_NOSTR, + }; + + if (criteria.source_record_type !== undefined) { + where.source_record_type = criteria.source_record_type; + } + if (criteria.source_identifier) { + where.source_identifier = criteria.source_identifier; + } + if (criteria.source_uri) { + where.source_uri = criteria.source_uri; + } + + return this.repository.find({ where }); } /** - * Finds all kind 30142 (AMB) events that don't have associated OER records. - * This is used for processing historical events after EOSE. + * Finds all pending kind 30142 (AMB) events that need OER extraction. + * These are events that have been ingested but not yet linked to an OER. * - * @returns Array of NostrEvents without OER records + * @returns Array of OerSources with pending AMB events */ - async findUnprocessedOerEvents(): Promise { + async findUnprocessedOerEvents(): Promise { try { - return await this.repository - .createQueryBuilder('event') - .leftJoin( - 'open_educational_resources', - 'oer', - 'oer.event_amb_id = event.id', - ) - .where('event.kind = :kind', { kind: EVENT_AMB_KIND }) - .andWhere('oer.id IS NULL') - .getMany(); + return await this.repository.find({ + where: { + source_name: SOURCE_NAME_NOSTR, + source_record_type: String(EVENT_AMB_KIND), + status: 'pending', + }, + }); } catch (error) { this.logger.error( `Failed to find unprocessed OER events: ${DatabaseErrorClassifier.extractErrorMessage(error)}`, @@ -122,46 +156,59 @@ export class NostrEventDatabaseService { } /** - * Counts total events in the database. + * Counts total Nostr events in the database. * Useful for monitoring and statistics. * * @returns Total event count */ async countEvents(): Promise { - return this.repository.count(); + return this.repository.count({ + where: { source_name: SOURCE_NAME_NOSTR }, + }); } /** - * Counts events by kind. + * Counts events by record type. * Useful for monitoring event type distribution. * - * @param kind - The event kind to count - * @returns Count of events with the specified kind + * @param recordType - The record type to count (e.g., '30142' for AMB events) + * @returns Count of events with the specified record type */ - async countEventsByKind(kind: number): Promise { - return this.repository.count({ where: { kind } }); + async countEventsByRecordType(recordType: string): Promise { + return this.repository.count({ + where: { + source_name: SOURCE_NAME_NOSTR, + source_record_type: recordType, + }, + }); } /** - * Gets the most recent event timestamp for specified event kinds. + * Gets the most recent source timestamp for specified record types. * Used to resume synchronization from the last known event on server restart. * - * @param kinds - Array of event kinds to consider - * @returns The most recent created_at timestamp, or null if no events exist - * @deprecated Use getLatestEventTimestampsByRelay for per-relay synchronization + * @param recordTypes - Array of record types to consider (e.g., ['30142', '1063']) + * @returns The most recent source_timestamp, or null if no events exist + * @deprecated Use getLatestTimestampsByRelay for per-relay synchronization */ - async getLatestEventTimestamp(kinds: number[]): Promise { + async getLatestTimestamp(recordTypes: string[]): Promise { try { const result = await this.repository - .createQueryBuilder('event') - .select('MAX(event.created_at)', 'max_timestamp') - .where('event.kind IN (:...kinds)', { kinds }) - .getRawOne<{ max_timestamp: number | null }>(); + .createQueryBuilder('source') + .select('MAX(source.source_timestamp)', 'max_timestamp') + .where('source.source_name = :sourceName', { + sourceName: SOURCE_NAME_NOSTR, + }) + .andWhere('source.source_record_type IN (:...recordTypes)', { + recordTypes, + }) + .getRawOne<{ max_timestamp: string | null }>(); - return result?.max_timestamp ?? null; + // source_timestamp is stored as bigint, which comes back as string + return result?.max_timestamp ? parseInt(result.max_timestamp, 10) : null; } catch (error) { this.logger.error( - `Failed to get latest event timestamp: ${DatabaseErrorClassifier.extractErrorMessage(error)}`, + `Failed to get latest timestamp: ${DatabaseErrorClassifier.extractErrorMessage(error)}`, DatabaseErrorClassifier.extractStackTrace(error), ); throw error; @@ -169,45 +216,90 @@ export class NostrEventDatabaseService { } /** - * Gets the most recent event timestamp per relay for specified event kinds. + * Gets the most recent timestamp per relay for specified record types. * Used to resume synchronization from each relay's last known event on server restart. * + * The source_uri field stores the relay URL directly (e.g., 'wss://relay.edufeed.org'). + * * @param relayUrls - Array of relay URLs to query - * @param kinds - Array of event kinds to consider - * @returns Map of relay URL to most recent created_at timestamp + * @param recordTypes - Array of record types to consider (e.g., ['30142', '1063']) + * @returns Map of relay URL to most recent source_timestamp */ - async getLatestEventTimestampsByRelay( + async getLatestTimestampsByRelay( relayUrls: readonly string[], - kinds: number[], + recordTypes: string[], ): Promise> { try { - const results = await this.repository - .createQueryBuilder('event') - .select('event.relay_url', 'relay_url') - .addSelect('MAX(event.created_at)', 'max_timestamp') - .where('event.relay_url IN (:...relayUrls)', { relayUrls }) - .andWhere('event.kind IN (:...kinds)', { kinds }) - .groupBy('event.relay_url') - .getRawMany<{ relay_url: string; max_timestamp: number }>(); - // Create a map with all relay URLs, defaulting to null for relays with no events const timestampMap = new Map(); for (const url of relayUrls) { timestampMap.set(url, null); } - // Update with actual timestamps from database - for (const result of results) { - timestampMap.set(result.relay_url, result.max_timestamp); + // Query each relay separately using exact match on source_uri + for (const relayUrl of relayUrls) { + const result = await this.repository + .createQueryBuilder('source') + .select('MAX(source.source_timestamp)', 'max_timestamp') + .where('source.source_name = :sourceName', { + sourceName: SOURCE_NAME_NOSTR, + }) + .andWhere('source.source_uri = :relayUrl', { relayUrl }) + .andWhere('source.source_record_type IN (:...recordTypes)', { + recordTypes, + }) + .getRawOne<{ max_timestamp: string | null }>(); + + if (result?.max_timestamp) { + timestampMap.set(relayUrl, parseInt(result.max_timestamp, 10)); + } } return timestampMap; } catch (error) { this.logger.error( - `Failed to get latest event timestamps by relay: ${DatabaseErrorClassifier.extractErrorMessage(error)}`, + `Failed to get latest timestamps by relay: ${DatabaseErrorClassifier.extractErrorMessage(error)}`, DatabaseErrorClassifier.extractStackTrace(error), ); throw error; } } + + /** + * Marks an event source as processed after successful OER extraction. + * + * @param eventId - The Nostr event ID + * @param oerId - The OER ID that was created/updated + */ + async markEventProcessed(eventId: string, oerId: string): Promise { + const sourceIdentifier = createNostrSourceIdentifier(eventId); + await this.repository.update( + { + source_name: SOURCE_NAME_NOSTR, + source_identifier: sourceIdentifier, + }, + { + status: 'processed', + oer_id: oerId, + }, + ); + } + + /** + * Marks an event source as failed after extraction failure. + * + * @param eventId - The Nostr event ID + */ + async markEventFailed(eventId: string): Promise { + const sourceIdentifier = createNostrSourceIdentifier(eventId); + await this.repository.update( + { + source_name: SOURCE_NAME_NOSTR, + source_identifier: sourceIdentifier, + }, + { + status: 'failed', + }, + ); + } } diff --git a/src/oer/__tests__/oer-extraction.service.spec.ts b/src/oer/__tests__/oer-extraction.service.spec.ts index 4f6c135..9218db6 100644 --- a/src/oer/__tests__/oer-extraction.service.spec.ts +++ b/src/oer/__tests__/oer-extraction.service.spec.ts @@ -3,7 +3,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { OerExtractionService } from '../services/oer-extraction.service'; import { OpenEducationalResource } from '../entities/open-educational-resource.entity'; -import { NostrEvent } from '../../nostr/entities/nostr-event.entity'; +import { OerSource } from '../entities/oer-source.entity'; import { EVENT_AMB_KIND, EVENT_FILE_KIND, @@ -13,11 +13,36 @@ import { eventFactoryHelpers, oerFactoryHelpers, } from '../../../test/fixtures'; +import { SOURCE_NAME_NOSTR } from '../constants'; + +/** + * Creates a mock OerSource from a NostrEvent-like object. + */ +function createOerSourceFromEvent( + eventData: ReturnType, + overrides: Partial = {}, +): OerSource { + return { + id: `source-${eventData.id}`, + oer_id: null, + oer: null, + source_name: SOURCE_NAME_NOSTR, + source_identifier: `event:${eventData.id}`, + source_data: eventData as unknown as Record, + status: 'pending', + source_uri: 'wss://relay.example.com', + source_timestamp: eventData.created_at, + source_record_type: String(eventData.kind), + created_at: new Date(), + updated_at: new Date(), + ...overrides, + }; +} describe('OerExtractionService', () => { let service: OerExtractionService; let oerRepository: Repository; - let nostrEventRepository: Repository; + let oerSourceRepository: Repository; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -29,10 +54,11 @@ describe('OerExtractionService', () => { create: jest.fn(), save: jest.fn(), findOne: jest.fn(), + createQueryBuilder: jest.fn(), }, }, { - provide: getRepositoryToken(NostrEvent), + provide: getRepositoryToken(OerSource), useValue: { create: jest.fn(), save: jest.fn(), @@ -46,9 +72,20 @@ describe('OerExtractionService', () => { oerRepository = module.get>( getRepositoryToken(OpenEducationalResource), ); - nostrEventRepository = module.get>( - getRepositoryToken(NostrEvent), + oerSourceRepository = module.get>( + getRepositoryToken(OerSource), ); + + // Set up default mocks for OerSource repository + jest.spyOn(oerSourceRepository, 'findOne').mockResolvedValue(null); + jest + .spyOn(oerSourceRepository, 'create') + .mockImplementation((entity) => entity as OerSource); + jest + .spyOn(oerSourceRepository, 'save') + .mockImplementation((entity) => + Promise.resolve({ ...entity, id: 'source-id' } as OerSource), + ); }); it('should be defined', () => { @@ -67,13 +104,19 @@ describe('OerExtractionService', () => { }); }); - describe('extractOerFromEvent', () => { + describe('extractOerFromSource', () => { it('should extract complete OER data from a kind 30142 (AMB) event with all fields', async () => { - const mockFileEvent = nostrEventFixtures.fileComplete({ + const mockFileEventData = nostrEventFixtures.fileComplete({ id: 'file123', }); + const mockFileSource = createOerSourceFromEvent( + mockFileEventData as ReturnType, + { + source_record_type: String(EVENT_FILE_KIND), + }, + ); - const mockNostrEvent = nostrEventFixtures.ambComplete({ + const mockAmbEventData = nostrEventFixtures.ambComplete({ id: 'event123', tags: [ ['d', 'https://example.edu/diagram.png'], @@ -89,6 +132,7 @@ describe('OerExtractionService', () => { ['e', 'file123', 'wss://relay.example.com', 'file'], ], }); + const mockAmbSource = createOerSourceFromEvent(mockAmbEventData); const mockOer = oerFactoryHelpers.createCompleteOer() as OpenEducationalResource; @@ -97,9 +141,10 @@ describe('OerExtractionService', () => { const oerFindOneSpy = jest .spyOn(oerRepository, 'findOne') .mockResolvedValue(null); - const nostrFindOneSpy = jest - .spyOn(nostrEventRepository, 'findOne') - .mockResolvedValue(mockFileEvent); + // Mock file source lookup + const sourceRepoFindOneSpy = jest + .spyOn(oerSourceRepository, 'findOne') + .mockResolvedValue(mockFileSource); const createSpy = jest .spyOn(oerRepository, 'create') .mockReturnValue(mockOer); @@ -107,18 +152,22 @@ describe('OerExtractionService', () => { .spyOn(oerRepository, 'save') .mockResolvedValue(mockOer); - const result = await service.extractOerFromEvent(mockNostrEvent); + const result = await service.extractOerFromSource(mockAmbSource); expect(oerFindOneSpy).toHaveBeenCalledWith({ - where: { url: 'https://example.edu/diagram.png' }, - relations: ['eventAmb'], + where: { url: 'https://example.edu/diagram.png', source_name: 'nostr' }, + relations: ['sources'], }); - expect(nostrFindOneSpy).toHaveBeenCalledWith({ - where: { id: 'file123' }, + expect(sourceRepoFindOneSpy).toHaveBeenCalledWith({ + where: { + source_name: SOURCE_NAME_NOSTR, + source_identifier: 'event:file123', + }, }); expect(createSpy).toHaveBeenCalledWith( expect.objectContaining({ url: 'https://example.edu/diagram.png', + source_name: 'nostr', license_uri: 'https://creativecommons.org/licenses/by-sa/4.0/', free_to_use: true, file_mime_type: 'image/png', @@ -129,8 +178,6 @@ describe('OerExtractionService', () => { description: 'A diagram showing photosynthesis', audience_uri: null, educational_level_uri: null, - event_amb_id: 'event123', - event_file_id: 'file123', }), ); expect(saveSpy).toHaveBeenCalled(); @@ -138,9 +185,10 @@ describe('OerExtractionService', () => { }); it('should extract OER with minimal data when fields are missing', async () => { - const mockNostrEvent = nostrEventFixtures.ambMinimal({ + const mockAmbEventData = nostrEventFixtures.ambMinimal({ id: 'event456', }); + const mockAmbSource = createOerSourceFromEvent(mockAmbEventData); const mockOer = oerFactoryHelpers.createMinimalOer() as OpenEducationalResource; @@ -151,11 +199,12 @@ describe('OerExtractionService', () => { .mockReturnValue(mockOer); jest.spyOn(oerRepository, 'save').mockResolvedValue(mockOer); - const result = await service.extractOerFromEvent(mockNostrEvent); + const result = await service.extractOerFromSource(mockAmbSource); expect(createSpy).toHaveBeenCalledWith( expect.objectContaining({ url: 'https://example.edu/resource.pdf', + source_name: 'nostr', license_uri: null, free_to_use: null, file_mime_type: null, @@ -166,43 +215,43 @@ describe('OerExtractionService', () => { description: null, audience_uri: null, educational_level_uri: null, - event_amb_id: 'event456', - event_file_id: null, }), ); expect(result).toEqual(mockOer); }); it('should handle missing file event gracefully', async () => { - const mockNostrEvent = eventFactoryHelpers.createAmbEvent({ + const mockAmbEventData = eventFactoryHelpers.createAmbEvent({ id: 'event789', tags: [ ['d', 'https://example.edu/image.png'], ['e', 'missing-file-event', 'wss://relay.example.com', 'file'], ], }); + const mockAmbSource = createOerSourceFromEvent(mockAmbEventData); const mockOer = oerFactoryHelpers.createMinimalOer({ id: 'oer-uuid-789', url: 'https://example.edu/image.png', - amb_metadata: { d: 'https://example.edu/image.png' }, - event_amb_id: 'event789', - event_file_id: null, + amb_metadata: {}, }) as OpenEducationalResource; jest.spyOn(oerRepository, 'findOne').mockResolvedValue(null); - const nostrFindOneSpy = jest - .spyOn(nostrEventRepository, 'findOne') + const sourceRepoFindOneSpy = jest + .spyOn(oerSourceRepository, 'findOne') .mockResolvedValue(null); const createSpy = jest .spyOn(oerRepository, 'create') .mockReturnValue(mockOer); jest.spyOn(oerRepository, 'save').mockResolvedValue(mockOer); - const result = await service.extractOerFromEvent(mockNostrEvent); + const result = await service.extractOerFromSource(mockAmbSource); - expect(nostrFindOneSpy).toHaveBeenCalledWith({ - where: { id: 'missing-file-event' }, + expect(sourceRepoFindOneSpy).toHaveBeenCalledWith({ + where: { + source_name: SOURCE_NAME_NOSTR, + source_identifier: 'event:missing-file-event', + }, }); expect(createSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -210,14 +259,13 @@ describe('OerExtractionService', () => { file_dim: null, file_size: null, file_alt: null, - event_file_id: null, // Should be null }), ); expect(result).toEqual(mockOer); }); it('should handle malformed boolean and number values gracefully', async () => { - const mockNostrEvent = eventFactoryHelpers.createAmbEvent({ + const mockAmbEventData = eventFactoryHelpers.createAmbEvent({ id: 'event-malformed', tags: [ ['d', 'https://example.edu/resource'], @@ -225,31 +273,36 @@ describe('OerExtractionService', () => { ['e', 'file-malformed', 'wss://relay.example.com', 'file'], ], }); + const mockAmbSource = createOerSourceFromEvent(mockAmbEventData); - const mockFileEvent = eventFactoryHelpers.createFileEvent({ + const mockFileEventData = eventFactoryHelpers.createFileEvent({ id: 'file-malformed', content: '', tags: [['size', 'not-a-number']], }); + const mockFileSource = createOerSourceFromEvent( + mockFileEventData as ReturnType, + { + source_record_type: String(EVENT_FILE_KIND), + }, + ); const mockOer = oerFactoryHelpers.createMinimalOer({ id: 'oer-malformed', url: 'https://example.edu/resource', - amb_metadata: { d: 'https://example.edu/resource' }, - event_amb_id: 'event-malformed', - event_file_id: 'file-malformed', + amb_metadata: {}, }) as OpenEducationalResource; jest.spyOn(oerRepository, 'findOne').mockResolvedValue(null); jest - .spyOn(nostrEventRepository, 'findOne') - .mockResolvedValue(mockFileEvent); + .spyOn(oerSourceRepository, 'findOne') + .mockResolvedValue(mockFileSource); const createSpy = jest .spyOn(oerRepository, 'create') .mockReturnValue(mockOer); jest.spyOn(oerRepository, 'save').mockResolvedValue(mockOer); - const result = await service.extractOerFromEvent(mockNostrEvent); + const result = await service.extractOerFromSource(mockAmbSource); expect(createSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -261,39 +314,44 @@ describe('OerExtractionService', () => { }); it('should use description tag if present, otherwise fall back to content', async () => { - const mockNostrEvent = eventFactoryHelpers.createAmbEvent({ + const mockAmbEventData = eventFactoryHelpers.createAmbEvent({ id: 'event-desc', tags: [ ['d', 'https://example.edu/resource'], ['e', 'file-desc', 'wss://relay.example.com', 'file'], ], }); + const mockAmbSource = createOerSourceFromEvent(mockAmbEventData); - const mockFileEvent = eventFactoryHelpers.createFileEvent({ + const mockFileEventData = eventFactoryHelpers.createFileEvent({ id: 'file-desc', content: 'Fallback content description', tags: [['description', 'Tag description takes priority']], }); + const mockFileSource = createOerSourceFromEvent( + mockFileEventData as ReturnType, + { + source_record_type: String(EVENT_FILE_KIND), + }, + ); const mockOer = oerFactoryHelpers.createMinimalOer({ id: 'oer-desc', url: 'https://example.edu/resource', - amb_metadata: { d: 'https://example.edu/resource' }, + amb_metadata: {}, description: 'Tag description takes priority', - event_amb_id: 'event-desc', - event_file_id: 'file-desc', }) as OpenEducationalResource; jest.spyOn(oerRepository, 'findOne').mockResolvedValue(null); jest - .spyOn(nostrEventRepository, 'findOne') - .mockResolvedValue(mockFileEvent); + .spyOn(oerSourceRepository, 'findOne') + .mockResolvedValue(mockFileSource); const createSpy = jest .spyOn(oerRepository, 'create') .mockReturnValue(mockOer); jest.spyOn(oerRepository, 'save').mockResolvedValue(mockOer); - const result = await service.extractOerFromEvent(mockNostrEvent); + const result = await service.extractOerFromSource(mockAmbSource); expect(createSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -306,24 +364,23 @@ describe('OerExtractionService', () => { }); }); - describe('extractOerFromEvent - URL uniqueness and upsert behavior', () => { + describe('extractOerFromSource - URL uniqueness and upsert behavior', () => { it('should create a new OER when URL does not exist', async () => { - const mockNostrEvent = eventFactoryHelpers.createAmbEvent({ + const mockAmbEventData = eventFactoryHelpers.createAmbEvent({ id: 'event-new', tags: [ ['d', 'https://example.edu/new-resource.png'], ['type', 'Image'], ], }); + const mockAmbSource = createOerSourceFromEvent(mockAmbEventData); const mockOer = oerFactoryHelpers.createMinimalOer({ id: 'oer-new', url: 'https://example.edu/new-resource.png', amb_metadata: { - d: 'https://example.edu/new-resource.png', type: 'Image', }, - event_amb_id: 'event-new', }) as OpenEducationalResource; // Mock oerRepository.findOne to return null (URL doesn't exist) @@ -337,11 +394,14 @@ describe('OerExtractionService', () => { .spyOn(oerRepository, 'save') .mockResolvedValue(mockOer); - const result = await service.extractOerFromEvent(mockNostrEvent); + const result = await service.extractOerFromSource(mockAmbSource); expect(findOneSpy).toHaveBeenCalledWith({ - where: { url: 'https://example.edu/new-resource.png' }, - relations: ['eventAmb'], + where: { + url: 'https://example.edu/new-resource.png', + source_name: 'nostr', + }, + relations: ['sources'], }); expect(createSpy).toHaveBeenCalled(); expect(saveSpy).toHaveBeenCalled(); @@ -349,25 +409,14 @@ describe('OerExtractionService', () => { }); it('should update existing OER when new dates are newer', async () => { - const olderEvent = eventFactoryHelpers.createAmbEvent({ - id: 'event-old', - created_at: 1000000000, - tags: [ - ['d', 'https://example.edu/resource.png'], - ['dateCreated', '2024-01-10T08:00:00Z'], - ['datePublished', '2024-01-12T10:00:00Z'], - ], - }); - const existingOer = oerFactoryHelpers.createExistingOerWithDates({ license_uri: 'https://old-license.org', amb_metadata: { type: 'OldType' }, keywords: ['old'], description: 'Old description', - eventAmb: olderEvent, }) as OpenEducationalResource; - const newerEvent = eventFactoryHelpers.createAmbEvent({ + const newerEventData = eventFactoryHelpers.createAmbEvent({ id: 'event-new', created_at: 2000000000, tags: [ @@ -380,13 +429,13 @@ describe('OerExtractionService', () => { ['datePublished', '2024-02-20T12:00:00Z'], ], }); + const newerSource = createOerSourceFromEvent(newerEventData); const updatedOer: OpenEducationalResource = { ...existingOer, license_uri: 'https://new-license.org', free_to_use: false, amb_metadata: { - d: 'https://example.edu/resource.png', 'license:id': 'https://new-license.org', isAccessibleForFree: 'false', type: 'NewType', @@ -394,7 +443,6 @@ describe('OerExtractionService', () => { datePublished: '2024-02-20T12:00:00Z', }, keywords: ['new-keyword'], - event_amb_id: 'event-new', updated_at: new Date(), }; @@ -405,43 +453,33 @@ describe('OerExtractionService', () => { .spyOn(oerRepository, 'save') .mockResolvedValue(updatedOer); - const result = await service.extractOerFromEvent(newerEvent); + await service.extractOerFromSource(newerSource); expect(findOneSpy).toHaveBeenCalledWith({ - where: { url: 'https://example.edu/resource.png' }, - relations: ['eventAmb'], + where: { + url: 'https://example.edu/resource.png', + source_name: 'nostr', + }, + relations: ['sources'], }); expect(saveSpy).toHaveBeenCalledWith( expect.objectContaining({ license_uri: 'https://new-license.org', free_to_use: false, keywords: ['new-keyword'], - event_amb_id: 'event-new', }), ); - expect(result.event_amb_id).toEqual('event-new'); }); it('should skip update when new dates are older or same', async () => { - const newerEvent = eventFactoryHelpers.createAmbEvent({ - id: 'event-newer', - created_at: 2000000000, - tags: [ - ['d', 'https://example.edu/resource.png'], - ['dateCreated', '2024-02-20T10:00:00Z'], - ], - }); - const existingOer = oerFactoryHelpers.createExistingOerWithDates({ license_uri: 'https://existing-license.org', amb_metadata: { dateCreated: '2024-02-20T10:00:00Z', }, - event_amb_id: 'event-newer', - eventAmb: newerEvent, }) as OpenEducationalResource; - const olderIncomingEvent = eventFactoryHelpers.createAmbEvent({ + const olderEventData = eventFactoryHelpers.createAmbEvent({ id: 'event-older', created_at: 1000000000, tags: [ @@ -450,44 +488,36 @@ describe('OerExtractionService', () => { ['dateCreated', '2024-01-15T08:00:00Z'], ], }); + const olderSource = createOerSourceFromEvent(olderEventData); const findOneSpy = jest .spyOn(oerRepository, 'findOne') .mockResolvedValue(existingOer); const saveSpy = jest.spyOn(oerRepository, 'save'); - const result = await service.extractOerFromEvent(olderIncomingEvent); + const result = await service.extractOerFromSource(olderSource); expect(findOneSpy).toHaveBeenCalledWith({ - where: { url: 'https://example.edu/resource.png' }, - relations: ['eventAmb'], + where: { + url: 'https://example.edu/resource.png', + source_name: 'nostr', + }, + relations: ['sources'], }); expect(saveSpy).not.toHaveBeenCalled(); expect(result).toEqual(existingOer); - expect(result.event_amb_id).toEqual('event-newer'); // Should still reference the newer event }); it('should skip update when dates are the same', async () => { const sameDate = '2024-01-15T10:00:00Z'; - const existingEvent = eventFactoryHelpers.createAmbEvent({ - id: 'event-existing', - created_at: 1500000000, - tags: [ - ['d', 'https://example.edu/resource.png'], - ['dateCreated', sameDate], - ], - }); - const existingOer = oerFactoryHelpers.createExistingOerWithDates({ amb_metadata: { dateCreated: sameDate, }, - event_amb_id: 'event-existing', - eventAmb: existingEvent, }) as OpenEducationalResource; - const sameAgeEvent = eventFactoryHelpers.createAmbEvent({ + const sameAgeEventData = eventFactoryHelpers.createAmbEvent({ id: 'event-same-age', created_at: 1600000000, tags: [ @@ -496,11 +526,12 @@ describe('OerExtractionService', () => { ['dateCreated', sameDate], ], }); + const sameAgeSource = createOerSourceFromEvent(sameAgeEventData); jest.spyOn(oerRepository, 'findOne').mockResolvedValue(existingOer); const saveSpy = jest.spyOn(oerRepository, 'save'); - const result = await service.extractOerFromEvent(sameAgeEvent); + const result = await service.extractOerFromSource(sameAgeSource); expect(saveSpy).not.toHaveBeenCalled(); expect(result).toEqual(existingOer); @@ -510,7 +541,7 @@ describe('OerExtractionService', () => { const existingOer = oerFactoryHelpers.createOerWithoutDates() as OpenEducationalResource; - const newEvent = eventFactoryHelpers.createAmbEvent({ + const newEventData = eventFactoryHelpers.createAmbEvent({ id: 'event-new', created_at: 1500000000, tags: [ @@ -519,15 +550,14 @@ describe('OerExtractionService', () => { ['dateCreated', '2024-01-20T10:00:00Z'], ], }); + const newSource = createOerSourceFromEvent(newEventData); const updatedOer = { ...existingOer, amb_metadata: { - d: 'https://example.edu/resource.png', type: 'NewType', dateCreated: '2024-01-20T10:00:00Z', }, - event_amb_id: 'event-new', }; jest.spyOn(oerRepository, 'findOne').mockResolvedValue(existingOer); @@ -535,11 +565,10 @@ describe('OerExtractionService', () => { .spyOn(oerRepository, 'save') .mockResolvedValue(updatedOer); - const result = await service.extractOerFromEvent(newEvent); + await service.extractOerFromSource(newSource); // Should update because existing has no dates to compare expect(saveSpy).toHaveBeenCalled(); - expect(result.event_amb_id).toEqual('event-new'); }); it('should skip update when new event has no date fields and existing OER exists', async () => { @@ -548,10 +577,9 @@ describe('OerExtractionService', () => { amb_metadata: { dateCreated: '2024-01-15T10:00:00Z', }, - event_amb_id: 'event-existing', }) as OpenEducationalResource; - const newEventWithoutDates = eventFactoryHelpers.createAmbEvent({ + const newEventWithoutDatesData = eventFactoryHelpers.createAmbEvent({ id: 'event-no-dates', created_at: 1600000000, tags: [ @@ -560,23 +588,25 @@ describe('OerExtractionService', () => { ['license:id', 'https://new-license.org'], ], }); + const newSourceWithoutDates = createOerSourceFromEvent( + newEventWithoutDatesData, + ); jest.spyOn(oerRepository, 'findOne').mockResolvedValue(existingOer); const saveSpy = jest.spyOn(oerRepository, 'save'); - const result = await service.extractOerFromEvent(newEventWithoutDates); + const result = await service.extractOerFromSource(newSourceWithoutDates); // Should NOT update because new event has no dateCreated, datePublished, or dateModified expect(saveSpy).not.toHaveBeenCalled(); expect(result).toEqual(existingOer); - expect(result.event_amb_id).toEqual('event-existing'); }); it('should extract and use dateModified from amb_metadata when comparing', async () => { const existingOer = oerFactoryHelpers.createOerWithModifiedDate() as OpenEducationalResource; - const newerEvent = eventFactoryHelpers.createAmbEvent({ + const newerEventData = eventFactoryHelpers.createAmbEvent({ id: 'event-new', created_at: 2000000000, tags: [ @@ -585,15 +615,14 @@ describe('OerExtractionService', () => { ['dateModified', '2024-02-20T10:00:00Z'], ], }); + const newerSource = createOerSourceFromEvent(newerEventData); const updatedOer: OpenEducationalResource = { ...existingOer, amb_metadata: { - d: 'https://example.edu/resource.png', type: 'NewType', dateModified: '2024-02-20T10:00:00Z', }, - event_amb_id: 'event-new', }; jest.spyOn(oerRepository, 'findOne').mockResolvedValue(existingOer); @@ -601,11 +630,10 @@ describe('OerExtractionService', () => { .spyOn(oerRepository, 'save') .mockResolvedValue(updatedOer); - const result = await service.extractOerFromEvent(newerEvent); + await service.extractOerFromSource(newerSource); // Should update because new dateModified is newer than existing dateModified expect(saveSpy).toHaveBeenCalled(); - expect(result.event_amb_id).toEqual('event-new'); }); }); }); diff --git a/src/oer/__tests__/oer-query.service.spec.ts b/src/oer/__tests__/oer-query.service.spec.ts index 1d6cb59..f634dcc 100644 --- a/src/oer/__tests__/oer-query.service.spec.ts +++ b/src/oer/__tests__/oer-query.service.spec.ts @@ -214,7 +214,8 @@ describe('OerQueryService', () => { expect.objectContaining({ id: '123', url: 'https://example.com/resource', - source: 'nostr', + source_name: 'nostr', + sources: [], creators: [], }), ], @@ -233,9 +234,10 @@ describe('OerQueryService', () => { expect(result.data[0]).toHaveProperty('audience_uri'); expect(result.data[0]).toHaveProperty('educational_level_uri'); - // Verify images, source, and creators are included + // Verify images, sources, and creators are included expect(result.data[0]).toHaveProperty('images'); - expect(result.data[0]).toHaveProperty('source'); + expect(result.data[0]).toHaveProperty('sources'); + expect(result.data[0]).toHaveProperty('source_name'); expect(result.data[0]).toHaveProperty('creators'); }); @@ -383,7 +385,9 @@ describe('OerQueryService', () => { ); expect(queryBuilder.getMany).not.toHaveBeenCalled(); expect(result.total).toBe(1); - expect(result.data[0].source).toBe('arasaac'); + expect(result.data[0].source_name).toBe('arasaac'); + expect(result.data[0].sources).toHaveLength(1); + expect(result.data[0].sources[0].source_name).toBe('arasaac'); }); }); }); diff --git a/src/oer/__tests__/oer.controller.spec.ts b/src/oer/__tests__/oer.controller.spec.ts index 04c15b3..0a88344 100644 --- a/src/oer/__tests__/oer.controller.spec.ts +++ b/src/oer/__tests__/oer.controller.spec.ts @@ -73,7 +73,6 @@ describe('OerController', () => { file_dim: '1920x1080', file_size: 100000, file_alt: 'Test image', - event_amb_id: 'event123', created_at: new Date('2024-01-01'), updated_at: new Date('2024-01-01'), }) as unknown as OerItem, diff --git a/src/oer/constants.ts b/src/oer/constants.ts index c60286c..a127165 100644 --- a/src/oer/constants.ts +++ b/src/oer/constants.ts @@ -1,4 +1,61 @@ /** - * Default source identifier for resources from the Nostr network. + * Source name constants for OER sources */ -export const DEFAULT_SOURCE = 'nostr'; +export const SOURCE_NAME_NOSTR = 'nostr'; +export const SOURCE_NAME_ARASAAC = 'arasaac'; +export const SOURCE_NAME_OPENVERSE = 'openverse'; + +/** + * Default source name for resources (Nostr network) + */ +export const DEFAULT_SOURCE = SOURCE_NAME_NOSTR; + +/** + * Helper function to create a source identifier for Nostr events + * @param eventId The Nostr event ID + * @returns Formatted source identifier (e.g., 'event:abc123') + */ +export function createNostrSourceIdentifier(eventId: string): string { + return `event:${eventId}`; +} + +/** + * Helper function to create a source identifier with relay information + * @param eventId The Nostr event ID + * @param relayUrl The relay URL (optional) + * @returns Formatted source identifier (e.g., 'event:abc123@relay:wss://relay.url') + */ +export function createNostrSourceIdentifierWithRelay( + eventId: string, + relayUrl?: string, +): string { + if (relayUrl) { + return `event:${eventId}@relay:${relayUrl}`; + } + return createNostrSourceIdentifier(eventId); +} + +/** + * Helper function to create a source URI for Nostr events. + * The source_uri stores just the relay URL for simplicity. + * The event ID is already stored in source_identifier. + * + * @param relayUrl The relay URL (e.g., 'wss://relay.edufeed.org') + * @returns The relay URL as source_uri + */ +export function createNostrSourceUri(relayUrl: string): string { + return relayUrl; +} + +/** + * Extracts the relay URL from a source URI. + * For Nostr events, the source_uri IS the relay URL. + * + * @param sourceUri The source URI (e.g., 'wss://relay.edufeed.org') + * @returns The relay URL or null if empty + */ +export function extractRelayUrlFromSourceUri( + sourceUri: string | null, +): string | null { + return sourceUri || null; +} diff --git a/src/oer/dto/oer-response-schema.dto.ts b/src/oer/dto/oer-response-schema.dto.ts index fb13265..da9b72b 100644 --- a/src/oer/dto/oer-response-schema.dto.ts +++ b/src/oer/dto/oer-response-schema.dto.ts @@ -461,20 +461,6 @@ export class OerItemSchema { }) foreign_landing_url: string | null; - @ApiProperty({ - description: 'Nostr event ID for the AMB event', - example: 'abc123def456', - nullable: true, - }) - event_amb_id: string | null; - - @ApiProperty({ - description: 'Nostr event ID for the file event', - example: 'xyz789uvw012', - nullable: true, - }) - event_file_id: string | null; - @ApiProperty({ description: 'Timestamp when the record was created in the database', example: '2024-01-01T00:00:00Z', diff --git a/src/oer/dto/oer-response.dto.ts b/src/oer/dto/oer-response.dto.ts index ccba302..cbccaf8 100644 --- a/src/oer/dto/oer-response.dto.ts +++ b/src/oer/dto/oer-response.dto.ts @@ -11,14 +11,41 @@ export interface OerMetadata { totalPages: number; } -// Omit TypeORM relations from API response, add images, source, and creators +/** + * Information about a source that provided data for an OER. + * Each OER can have multiple sources that contributed to its metadata. + */ +export interface OerSourceInfo { + /** + * Name/category of the source system (e.g., 'nostr', 'arasaac', 'openverse') + */ + source_name: string; + + /** + * Optional detailed identifier within the source type. + * For Nostr: 'event:{event_id}' or 'event:{id}@relay:{url}' + * For external APIs: Resource ID, API endpoint, etc. + */ + source_identifier: string | null; + + /** + * When this source was first associated with the OER + */ + created_at: Date; +} + +// Omit TypeORM relations from API response, add images, sources, and creators // created_at and updated_at can be null for external adapter items (not stored in DB) export type OerItem = Omit< OpenEducationalResource, - 'eventAmb' | 'eventFile' | 'created_at' | 'updated_at' + 'sources' | 'created_at' | 'updated_at' > & { images: ImageUrls | null; - source: string; + /** + * All sources that have provided data for this OER. + * Ordered by created_at (oldest first) for deterministic ordering. + */ + sources: OerSourceInfo[]; creators: Creator[]; created_at: Date | null; updated_at: Date | null; diff --git a/src/oer/entities/oer-source.entity.ts b/src/oer/entities/oer-source.entity.ts new file mode 100644 index 0000000..ce28f4f --- /dev/null +++ b/src/oer/entities/oer-source.entity.ts @@ -0,0 +1,123 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { OpenEducationalResource } from './open-educational-resource.entity'; + +/** + * Status of an OER source record. + * - pending: Event ingested but not yet linked to an OER (e.g., file events awaiting AMB event) + * - processed: Successfully linked to an OER + * - failed: Processing failed + */ +export type OerSourceStatus = 'pending' | 'processed' | 'failed'; + +/** + * Represents a source that provided data for an Open Educational Resource. + * Multiple sources can reference the same OER, allowing provenance tracking. + * + * This table also stores pending events (e.g., file events) that haven't been + * linked to an OER yet, enabling full replacement of the nostr_events table. + * + * Examples: + * - Nostr: source_name='nostr', source_identifier='event:abc123' + * - Arasaac: source_name='arasaac', source_identifier='resource:12345' + * - Openverse: source_name='openverse', source_identifier='search:query-id' + */ +@Entity('oer_sources') +export class OerSource { + @PrimaryGeneratedColumn('uuid') + id: string; + + /** + * Reference to the OER this source belongs to. + * Nullable to allow storing pending events (e.g., file events) before their OER exists. + */ + @Column({ type: 'uuid', nullable: true }) + @Index() + oer_id: string | null; + + @ManyToOne(() => OpenEducationalResource, (oer) => oer.sources, { + nullable: true, + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'oer_id' }) + oer: OpenEducationalResource | null; + + /** + * Name/category of the source system (e.g., 'nostr', 'arasaac', 'openverse') + */ + @Column({ type: 'text' }) + @Index() + source_name: string; + + /** + * Optional detailed identifier within the source type. + * Format conventions: + * - Nostr: 'event:{event_id}' or 'relay:{relay_url}' or 'event:{id}@relay:{url}' + * - External APIs: Resource ID, API endpoint, search query ID, etc. + */ + @Column({ type: 'text', nullable: true }) + source_identifier: string | null; + + /** + * Raw data from the source, stored as JSONB. + * - For Nostr: Complete Nostr event object + * - For external APIs: Complete API response + * - Useful for auditing, debugging, and potential re-processing + */ + @Column({ type: 'jsonb' }) + source_data: Record; + + /** + * Processing status of the source record. + * - pending: Ingested but not yet linked to an OER + * - processed: Successfully linked to an OER + * - failed: Processing failed + */ + @Column({ type: 'text', default: 'processed' }) + @Index() + status: OerSourceStatus; + + /** + * Full URI for the source, including server/relay info. + * Examples: + * - Nostr: 'wss://relay.edufeed.org' + * - API: 'https://api.arasaac.org/v1/pictograms' + */ + @Column({ type: 'text', nullable: true }) + @Index() + source_uri: string | null; + + /** + * Original timestamp from the source system. + * Used for sync resume functionality - to know where to resume from. + */ + @Column({ type: 'bigint', nullable: true }) + @Index() + source_timestamp: number | null; + + /** + * Record type/classification within the source system. + * Examples: + * - Nostr: '30142' (AMB event), '1063' (File event) + * - Arasaac: 'pictogram', 'material' + * - Openverse: 'image', 'audio' + */ + @Column({ type: 'text', nullable: true }) + @Index() + source_record_type: string | null; + + @CreateDateColumn() + @Index() + created_at: Date; + + @UpdateDateColumn() + updated_at: Date; +} diff --git a/src/oer/entities/open-educational-resource.entity.ts b/src/oer/entities/open-educational-resource.entity.ts index 26906c4..0ea3569 100644 --- a/src/oer/entities/open-educational-resource.entity.ts +++ b/src/oer/entities/open-educational-resource.entity.ts @@ -4,21 +4,33 @@ import { PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, - ManyToOne, - JoinColumn, + OneToMany, Index, } from 'typeorm'; -import { NostrEvent } from '../../nostr/entities/nostr-event.entity'; +import { OerSource } from './oer-source.entity'; @Entity('open_educational_resources') +@Index(['url', 'source_name'], { unique: true }) export class OpenEducationalResource { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ type: 'text', nullable: true, unique: true }) - @Index({ unique: true }) + @Column({ type: 'text', nullable: true }) + @Index() url: string | null; + /** + * The source system that owns/controls this OER entry. + * This determines which system is authoritative for this resource. + * Examples: 'nostr', 'arasaac', 'openverse' + * + * Combined with url, forms the unique identity of an OER. + * The same URL from different sources will be stored as separate entries. + */ + @Column({ type: 'text' }) + @Index() + source_name: string; + @Column({ type: 'text', nullable: true }) @Index() license_uri: string | null; @@ -63,25 +75,13 @@ export class OpenEducationalResource { @Index() educational_level_uri: string | null; - @Column({ type: 'text', nullable: true }) - @Index() - source: string | null; - - @Column({ type: 'text', nullable: true }) - @Index() - event_amb_id: string | null; - - @ManyToOne(() => NostrEvent, { nullable: true, onDelete: 'CASCADE' }) - @JoinColumn({ name: 'event_amb_id' }) - eventAmb: NostrEvent | null; - - @Column({ type: 'text', nullable: true }) - @Index() - event_file_id: string | null; - - @ManyToOne(() => NostrEvent, { nullable: true, onDelete: 'SET NULL' }) - @JoinColumn({ name: 'event_file_id' }) - eventFile: NostrEvent | null; + /** + * All sources that have provided data for this OER + */ + @OneToMany(() => OerSource, (source) => source.oer, { + cascade: true, + }) + sources: OerSource[]; @CreateDateColumn() @Index() diff --git a/src/oer/oer.module.ts b/src/oer/oer.module.ts index 9677566..02a9219 100644 --- a/src/oer/oer.module.ts +++ b/src/oer/oer.module.ts @@ -1,6 +1,7 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { OpenEducationalResource } from './entities/open-educational-resource.entity'; +import { OerSource } from './entities/oer-source.entity'; import { OerExtractionService } from './services/oer-extraction.service'; import { OerQueryService } from './services/oer-query.service'; import { ImgproxyService } from './services/imgproxy.service'; @@ -10,7 +11,7 @@ import { AdapterModule } from '../adapter'; @Module({ imports: [ - TypeOrmModule.forFeature([OpenEducationalResource]), + TypeOrmModule.forFeature([OpenEducationalResource, OerSource]), forwardRef(() => NostrModule), AdapterModule, ], diff --git a/src/oer/schemas/__tests__/amb-metadata.schema.spec.ts b/src/oer/schemas/__tests__/amb-metadata.schema.spec.ts new file mode 100644 index 0000000..6ee87eb --- /dev/null +++ b/src/oer/schemas/__tests__/amb-metadata.schema.spec.ts @@ -0,0 +1,166 @@ +import { filterAmbMetadata, ALLOWED_AMB_FIELDS } from '../amb-metadata.schema'; + +describe('AMB Metadata Schema', () => { + describe('ALLOWED_AMB_FIELDS', () => { + it('should contain expected AMB fields', () => { + expect(ALLOWED_AMB_FIELDS).toContain('name'); + expect(ALLOWED_AMB_FIELDS).toContain('type'); + expect(ALLOWED_AMB_FIELDS).toContain('description'); + expect(ALLOWED_AMB_FIELDS).toContain('creator'); + expect(ALLOWED_AMB_FIELDS).toContain('license'); + expect(ALLOWED_AMB_FIELDS).toContain('learningResourceType'); + expect(ALLOWED_AMB_FIELDS).toContain('dateCreated'); + expect(ALLOWED_AMB_FIELDS).toContain('datePublished'); + expect(ALLOWED_AMB_FIELDS).toContain('inLanguage'); + }); + + it('should not contain Nostr-specific tags', () => { + expect(ALLOWED_AMB_FIELDS).not.toContain('d'); + expect(ALLOWED_AMB_FIELDS).not.toContain('e'); + expect(ALLOWED_AMB_FIELDS).not.toContain('t'); + expect(ALLOWED_AMB_FIELDS).not.toContain('p'); + }); + }); + + describe('filterAmbMetadata', () => { + it('should preserve valid AMB fields', () => { + const input = { + name: 'Test Resource', + type: 'LearningResource', + description: 'A test description', + creator: { name: 'Test Author' }, + license: { id: 'https://creativecommons.org/licenses/by-sa/4.0/' }, + }; + + const result = filterAmbMetadata(input); + + expect(result).toEqual(input); + }); + + it('should strip Nostr-specific tags', () => { + const input = { + d: 'https://example.com/resource', + e: '91777b91b9807b7dfb4016640cc729a2af5b0059caa4f326d368344507989d6c', + t: 'mobile', + name: 'Test Resource', + type: 'Image', + }; + + const result = filterAmbMetadata(input); + + expect(result).toEqual({ + name: 'Test Resource', + type: 'Image', + }); + expect(result).not.toHaveProperty('d'); + expect(result).not.toHaveProperty('e'); + expect(result).not.toHaveProperty('t'); + }); + + it('should strip all single-letter Nostr tags', () => { + const input = { + d: 'url', + e: 'event-id', + t: 'tag', + p: 'pubkey', + a: 'address', + name: 'Keep this', + }; + + const result = filterAmbMetadata(input); + + expect(result).toEqual({ name: 'Keep this' }); + }); + + it('should pass through nested objects unchanged', () => { + const input = { + name: 'Test', + creator: { + name: 'Author Name', + type: 'Person', + affiliation: { + name: 'University', + custom_field: 'should be preserved', + }, + }, + learningResourceType: { + id: 'http://w3id.org/kim/hcrt/image', + 'prefLabel@en': 'Image', + 'prefLabel@de': 'Bild', + }, + }; + + const result = filterAmbMetadata(input); + + expect(result).toEqual(input); + expect((result.creator as Record).affiliation).toEqual({ + name: 'University', + custom_field: 'should be preserved', + }); + }); + + it('should handle empty input', () => { + const result = filterAmbMetadata({}); + + expect(result).toEqual({}); + }); + + it('should handle input with only Nostr-specific fields', () => { + const input = { + d: 'url', + e: 'event', + t: 'tag', + }; + + const result = filterAmbMetadata(input); + + expect(result).toEqual({}); + }); + + it('should handle realistic Nostr event metadata', () => { + // This simulates what parseColonSeparatedTags would produce from a real event + const input = { + d: 'https://download.sodix.de/dlms/a6214a68de8d32d2/resource', + e: '91777b91b9807b7dfb4016640cc729a2af5b0059caa4f326d368344507989d6c', + t: 'mobile', + name: 'Car', + type: 'Image', + description: 'Car', + dateCreated: '2025-01-15', + datePublished: '2025-01-20', + learningResourceType: { + id: 'http://w3id.org/kim/hcrt/image', + 'prefLabel@en': 'Image', + 'prefLabel@de': 'Bild', + }, + license: { + id: 'https://creativecommons.org/licenses/by-sa/4.0/', + }, + isAccessibleForFree: 'true', + inLanguage: ['en'], + creator: { + name: 'Siemens Stiftung 2018', + }, + }; + + const result = filterAmbMetadata(input); + + // Should not have Nostr-specific fields + expect(result).not.toHaveProperty('d'); + expect(result).not.toHaveProperty('e'); + expect(result).not.toHaveProperty('t'); + + // Should have all AMB fields + expect(result).toHaveProperty('name', 'Car'); + expect(result).toHaveProperty('type', 'Image'); + expect(result).toHaveProperty('description', 'Car'); + expect(result).toHaveProperty('dateCreated', '2025-01-15'); + expect(result).toHaveProperty('datePublished', '2025-01-20'); + expect(result).toHaveProperty('learningResourceType'); + expect(result).toHaveProperty('license'); + expect(result).toHaveProperty('isAccessibleForFree', 'true'); + expect(result).toHaveProperty('inLanguage'); + expect(result).toHaveProperty('creator'); + }); + }); +}); diff --git a/src/oer/schemas/amb-metadata.schema.ts b/src/oer/schemas/amb-metadata.schema.ts new file mode 100644 index 0000000..f0a8a71 --- /dev/null +++ b/src/oer/schemas/amb-metadata.schema.ts @@ -0,0 +1,75 @@ +/** + * AMB (Allgemeines Metadatenprofil für Bildungsressourcen) metadata schema. + * + * This module defines the allowed top-level fields for AMB metadata + * and provides a filter function to strip non-AMB fields (like Nostr-specific tags). + * + * Reference: https://dini-ag-kim.github.io/amb/draft/#json-schema + */ + +/** + * Allowed top-level field names according to AMB JSON Schema. + * Only first-layer validation - nested objects are passed through as-is. + */ +export const ALLOWED_AMB_FIELDS = [ + 'id', + 'type', + 'name', + 'description', + 'about', + 'keywords', + 'inLanguage', + 'image', + 'trailer', + 'creator', + 'contributor', + 'publisher', + 'funder', + 'dateCreated', + 'datePublished', + 'dateModified', + 'license', + 'conditionsOfAccess', + 'isAccessibleForFree', + 'learningResourceType', + 'audience', + 'educationalLevel', + 'interactivityType', + 'teaches', + 'assesses', + 'competencyRequired', + 'isBasedOn', + 'isPartOf', + 'hasPart', + 'mainEntityOfPage', + 'duration', + 'encoding', + 'caption', +] as const; + +/** + * Type for allowed AMB field names. + */ +export type AllowedAmbField = (typeof ALLOWED_AMB_FIELDS)[number]; + +/** + * Set for O(1) lookup of allowed fields. + */ +const ALLOWED_AMB_FIELDS_SET: ReadonlySet = new Set(ALLOWED_AMB_FIELDS); + +/** + * Filters parsed metadata to only include allowed AMB fields. + * Strips Nostr-specific tags like 'd', 'e', 't', 'p' etc. + * + * Only validates first-layer keys - nested objects are passed through unchanged. + * + * @param raw - Raw parsed metadata from Nostr event tags + * @returns Filtered metadata containing only AMB-compliant fields + */ +export function filterAmbMetadata( + raw: Record, +): Record { + return Object.fromEntries( + Object.entries(raw).filter(([key]) => ALLOWED_AMB_FIELDS_SET.has(key)), + ); +} diff --git a/src/oer/services/oer-extraction.service.ts b/src/oer/services/oer-extraction.service.ts index b8181ad..5ba89e7 100644 --- a/src/oer/services/oer-extraction.service.ts +++ b/src/oer/services/oer-extraction.service.ts @@ -1,13 +1,12 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, Not, IsNull } from 'typeorm'; -import { NostrEvent } from '../../nostr/entities/nostr-event.entity'; +import { Repository } from 'typeorm'; import { OpenEducationalResource } from '../entities/open-educational-resource.entity'; +import { OerSource } from '../entities/oer-source.entity'; import { parseColonSeparatedTags, extractTagValues, findTagValue, - findEventIdByMarker, parseBoolean, parseBigInt, } from '../../common/utils/tag-parser.util'; @@ -24,7 +23,22 @@ import { AmbMetadata, FileMetadata, } from '../types/extraction.types'; -import { DEFAULT_SOURCE } from '../constants'; +import { SOURCE_NAME_NOSTR, createNostrSourceIdentifier } from '../constants'; +import { filterAmbMetadata } from '../schemas/amb-metadata.schema'; + +/** + * Represents a Nostr event stored in source_data. + * Used for extracting metadata from raw event data. + */ +interface NostrEventData { + id: string; + kind: number; + pubkey: string; + created_at: number; + content: string; + tags: string[][]; + sig: string; +} @Injectable() export class OerExtractionService { @@ -33,35 +47,100 @@ export class OerExtractionService { constructor( @InjectRepository(OpenEducationalResource) private readonly oerRepository: Repository, - @InjectRepository(NostrEvent) - private readonly nostrEventRepository: Repository, + @InjectRepository(OerSource) + private readonly oerSourceRepository: Repository, ) {} /** - * Extracts OER metadata from a kind 30142 (AMB) Nostr event and creates or updates an OER record. + * Backwards-compatible method for extracting OER from a NostrEvent-like object. + * Creates a temporary OerSource and delegates to extractOerFromSource. + * + * @deprecated Use extractOerFromSource instead + * @param nostrEvent - The kind 30142 (AMB) Nostr event to extract from + * @returns The created or updated OER record + */ + async extractOerFromEvent(nostrEvent: { + id: string; + kind: number; + pubkey: string; + created_at: number; + content: string; + tags: string[][]; + sig?: string; + relay_url?: string | null; + raw_event?: Record; + }): Promise { + const sourceIdentifier = createNostrSourceIdentifier(nostrEvent.id); + + // Check if a source with this identifier already exists + let source = await this.oerSourceRepository.findOne({ + where: { + source_name: SOURCE_NAME_NOSTR, + source_identifier: sourceIdentifier, + }, + }); + + if (source) { + // Update existing source with latest data + source.source_data = + nostrEvent.raw_event || + (nostrEvent as unknown as Record); + source.source_uri = nostrEvent.relay_url || source.source_uri; + source.source_timestamp = nostrEvent.created_at; + source.source_record_type = String(nostrEvent.kind); + source.updated_at = new Date(); + } else { + // Create a new OerSource object + source = { + id: crypto.randomUUID(), + oer_id: null, + oer: null, + source_name: SOURCE_NAME_NOSTR, + source_identifier: sourceIdentifier, + source_data: + nostrEvent.raw_event || + (nostrEvent as unknown as Record), + status: 'pending', + source_uri: nostrEvent.relay_url || null, + source_timestamp: nostrEvent.created_at, + source_record_type: String(nostrEvent.kind), + created_at: new Date(), + updated_at: new Date(), + } as OerSource; + } + + return this.extractOerFromSource(source); + } + + /** + * Extracts OER metadata from a kind 30142 (AMB) OerSource and creates or updates an OER record. * This method is called synchronously during event ingestion. * If a record with the same URL already exists, it will be updated only if the new event is newer. * - * @param nostrEvent - The kind 30142 (AMB) Nostr event to extract from + * @param oerSource - The OerSource containing a kind 30142 (AMB) Nostr event * @returns The created or updated OER record */ - async extractOerFromEvent( - nostrEvent: NostrEvent, + async extractOerFromSource( + oerSource: OerSource, ): Promise { + // Extract the Nostr event data from source_data + const nostrEvent = oerSource.source_data as unknown as NostrEventData; + try { this.logger.debug( - `Extracting OER from event ${nostrEvent.id} (kind: ${nostrEvent.kind})`, + `Extracting OER from source ${oerSource.id} (event: ${nostrEvent.id}, kind: ${nostrEvent.kind})`, ); // Extract all AMB metadata from the event const ambMetadata = this.extractAmbMetadata(nostrEvent); - // Check if an OER with this URL already exists + // Check if an OER with this URL and source already exists + // Each source has its own entry for the same URL (unique constraint on url + source_name) let existingOer: OpenEducationalResource | null = null; if (ambMetadata.url) { existingOer = await this.oerRepository.findOne({ - where: { url: ambMetadata.url }, - relations: ['eventAmb'], + where: { url: ambMetadata.url, source_name: SOURCE_NAME_NOSTR }, + relations: ['sources'], }); } @@ -94,29 +173,33 @@ export class OerExtractionService { let oer: OpenEducationalResource; if (existingOer) { // Update existing record - this.populateOerEntity( - existingOer, - ambMetadata, - fileMetadata, - nostrEvent.id, - ); + this.populateOerEntity(existingOer, ambMetadata, fileMetadata); oer = existingOer; } else { // Create new record with all fields oer = this.oerRepository.create( - this.buildOerObject(ambMetadata, fileMetadata, nostrEvent.id), + this.buildOerObject(ambMetadata, fileMetadata), ); } - // Save with race condition protection - return this.saveOerWithRaceProtection( + // Save OER with race condition protection + const savedOer = await this.saveOerWithRaceProtection( oer, ambMetadata.url, !!existingOer, ); + + // Create or update OerSource entries for this Nostr event + await this.createOrUpdateOerSources( + savedOer, + oerSource, + ambMetadata.fileEventId, + ); + + return savedOer; } catch (error) { this.logger.error( - `Failed to extract OER from event ${nostrEvent.id}: ${error}`, + `Failed to extract OER from source ${oerSource.id}: ${error}`, error instanceof Error ? error.stack : undefined, ); throw error; @@ -134,59 +217,94 @@ export class OerExtractionService { } /** - * Finds OER records that have an event_file_id but are missing file metadata. - * This happens when the OER was extracted before the file event arrived. + * Finds OER records that are missing file metadata. + * This happens when the OER was extracted before the file event arrived, + * or when file metadata extraction failed. * * @returns Array of OER records with missing file metadata */ async findOersWithMissingFileMetadata(): Promise { - return this.oerRepository.find({ - where: { - event_file_id: Not(IsNull()), - file_mime_type: IsNull(), - }, - }); + // Find OERs that have no file_mime_type (indicating missing file metadata) + // and have at least one Nostr source (which may reference file events) + return this.oerRepository + .createQueryBuilder('oer') + .leftJoin('oer.sources', 'source') + .where('oer.file_mime_type IS NULL') + .andWhere('source.source_name = :sourceName', { + sourceName: SOURCE_NAME_NOSTR, + }) + .getMany(); } /** - * Updates an OER record with file metadata from its referenced file event. + * Updates an OER record with file metadata from Nostr file events in its sources. + * Searches through the OER's sources to find file event references. * - * @param oer - The OER record to update - * @returns The updated OER record, or the original if no file event found + * @param oer - The OER record to update (must include sources relation) + * @returns The updated OER record, or the original if no file metadata found */ async updateFileMetadata( oer: OpenEducationalResource, ): Promise { - if (!oer.event_file_id) { - this.logger.debug(`OER ${oer.id} has no event_file_id, skipping`); - return oer; + // Load sources if not already loaded + if (!oer.sources) { + const loaded = await this.oerRepository.findOne({ + where: { id: oer.id }, + relations: ['sources'], + }); + if (!loaded) { + this.logger.warn(`OER ${oer.id} not found`); + return oer; + } + oer = loaded; } try { - // Extract file metadata using shared method - const fileMetadata = await this.extractFileMetadata(oer.event_file_id); - - if (!fileMetadata) { - this.logger.warn( - `Could not extract file metadata for OER ${oer.id} from event ${oer.event_file_id}`, - ); + // Look for file event IDs in the source_identifier field + const fileEventIds = oer.sources + .filter( + (source) => + source.source_name === SOURCE_NAME_NOSTR && + source.source_identifier?.startsWith('event:'), + ) + .map((source) => { + // Extract event ID from 'event:abc123' or 'event:abc123@relay:...' + const match = source.source_identifier?.match(/^event:([^@]+)/); + return match ? match[1] : null; + }) + .filter((id): id is string => id !== null); + + if (fileEventIds.length === 0) { + this.logger.debug(`OER ${oer.id} has no file event IDs, skipping`); return oer; } - // Update OER record - oer.file_mime_type = fileMetadata.mimeType; - oer.file_dim = fileMetadata.dim; - oer.file_size = fileMetadata.size; - oer.file_alt = fileMetadata.alt; - oer.description = fileMetadata.description; + // Try each file event ID until we find file metadata + for (const fileEventId of fileEventIds) { + const fileMetadata = await this.extractFileMetadata(fileEventId); - const updatedOer = await this.oerRepository.save(oer); + if (fileMetadata) { + // Update OER record with file metadata + oer.file_mime_type = fileMetadata.mimeType; + oer.file_dim = fileMetadata.dim; + oer.file_size = fileMetadata.size; + oer.file_alt = fileMetadata.alt; + oer.description = fileMetadata.description; - this.logger.log( - `Successfully updated file metadata for OER ${oer.id} from event ${oer.event_file_id}`, - ); + const updatedOer = await this.oerRepository.save(oer); + + this.logger.log( + `Successfully updated file metadata for OER ${oer.id} from event ${fileEventId}`, + ); - return updatedOer; + return updatedOer; + } + } + + this.logger.warn( + `Could not extract file metadata for OER ${oer.id} from any source`, + ); + return oer; } catch (error) { this.logger.error( `Failed to update file metadata for OER ${oer.id}: ${error}`, @@ -277,23 +395,23 @@ export class OerExtractionService { } /** - * Extracts file metadata fields from a kind 1063 (File) Nostr event. - * This is the shared implementation used by both extractOerFromEvent and updateFileMetadata. + * Extracts file metadata fields from a kind 1063 (File) Nostr event data. + * This is the shared implementation used by both extractOerFromSource and updateFileMetadata. * - * @param fileEvent - The kind 1063 (File) event to extract from + * @param fileEventData - The kind 1063 (File) event data to extract from * @returns File metadata fields */ - private extractFileMetadataFromEvent( - fileEvent: NostrEvent, + private extractFileMetadataFromEventData( + fileEventData: NostrEventData, ): FileMetadataFields { - const mimeType = findTagValue(fileEvent.tags, 'm'); - const dim = findTagValue(fileEvent.tags, 'dim'); - const sizeStr = findTagValue(fileEvent.tags, 'size'); + const mimeType = findTagValue(fileEventData.tags, 'm'); + const dim = findTagValue(fileEventData.tags, 'dim'); + const sizeStr = findTagValue(fileEventData.tags, 'size'); const size = parseBigInt(sizeStr); - const alt = findTagValue(fileEvent.tags, 'alt'); + const alt = findTagValue(fileEventData.tags, 'alt'); const description = - findTagValue(fileEvent.tags, 'description') || - (fileEvent.content ? fileEvent.content : null); + findTagValue(fileEventData.tags, 'description') || + (fileEventData.content ? fileEventData.content : null); return { mimeType, @@ -427,7 +545,9 @@ export class OerExtractionService { } // Special case: Missing file metadata - const isMissingFileMetadata = !existing.event_file_id && newFileEventId; + // Check if we have no file metadata but the new event has a file reference + const isMissingFileMetadata = + !existing.file_mime_type && newFileEventId !== null; if (isMissingFileMetadata) { return { shouldUpdate: true, @@ -466,17 +586,19 @@ export class OerExtractionService { } /** - * Extracts all AMB metadata from a Nostr event into a structured object. + * Extracts all AMB metadata from a Nostr event data into a structured object. * - * @param nostrEvent - The kind 30142 (AMB) event to extract from + * @param nostrEventData - The kind 30142 (AMB) event data to extract from * @returns AMB metadata object */ - private extractAmbMetadata(nostrEvent: NostrEvent): AmbMetadata { + private extractAmbMetadata(nostrEventData: NostrEventData): AmbMetadata { // Extract URL from "d" tag (the resource URL) - const url = findTagValue(nostrEvent.tags, 'd'); + const url = findTagValue(nostrEventData.tags, 'd'); // Parse all tags to create nested JSON metadata structure - const parsedMetadata = parseColonSeparatedTags(nostrEvent.tags); + // Then filter to only include AMB-compliant fields (strips Nostr-specific tags like d, e, t) + const rawParsedMetadata = parseColonSeparatedTags(nostrEventData.tags); + const parsedMetadata = filterAmbMetadata(rawParsedMetadata); // Normalize inLanguage field to always be an array const normalizedLanguage = this.normalizeInLanguage( @@ -503,13 +625,13 @@ export class OerExtractionService { ); // Extract license information - const license = this.extractLicenseInfo(nostrEvent.tags); + const license = this.extractLicenseInfo(nostrEventData.tags); // Extract keywords from "t" tags - const keywords = this.extractKeywords(nostrEvent.tags); + const keywords = this.extractKeywords(nostrEventData.tags); - // Extract file event reference - const fileEventId = findEventIdByMarker(nostrEvent.tags, 'file'); + // Extract file event reference (first 'e' tag pointing to a kind 1063 event) + const fileEventId = findTagValue(nostrEventData.tags, 'e'); return { url, @@ -526,19 +648,20 @@ export class OerExtractionService { /** * Builds an OER object from extracted metadata. * Returns a plain object suitable for passing to repository.create(). + * Source tracking is handled separately via OerSource entities. * * @param ambMetadata - AMB metadata extracted from the event * @param fileMetadata - File metadata (if available) - * @param eventAmbId - The AMB event ID * @returns Plain object with OER fields */ private buildOerObject( ambMetadata: AmbMetadata, fileMetadata: FileMetadata | null, - eventAmbId: string, ): Partial { return { url: ambMetadata.url, + // source_name identifies which source system owns/controls this OER entry + source_name: SOURCE_NAME_NOSTR, license_uri: ambMetadata.license.uri, free_to_use: ambMetadata.license.freeToUse, file_mime_type: fileMetadata?.mimeType ?? null, @@ -550,28 +673,28 @@ export class OerExtractionService { description: fileMetadata?.description ?? null, audience_uri: ambMetadata.audienceUri, educational_level_uri: ambMetadata.educationalLevelUri, - source: DEFAULT_SOURCE, - event_amb_id: eventAmbId, - event_file_id: fileMetadata?.eventId ?? null, + name: ambMetadata.parsedMetadata.name as string | null, + attribution: ambMetadata.parsedMetadata.author as string | null, }; } /** * Populates an OER entity with extracted metadata. * This method is used for updating existing records. + * Source tracking is handled separately via OerSource entities. * * @param oer - The OER entity to populate * @param ambMetadata - AMB metadata extracted from the event * @param fileMetadata - File metadata (if available) - * @param eventAmbId - The AMB event ID */ private populateOerEntity( oer: OpenEducationalResource, ambMetadata: AmbMetadata, fileMetadata: FileMetadata | null, - eventAmbId: string, ): void { oer.url = ambMetadata.url; + // source_name should already be set for existing records, but ensure it's correct + oer.source_name = SOURCE_NAME_NOSTR; oer.license_uri = ambMetadata.license.uri; oer.free_to_use = ambMetadata.license.freeToUse; oer.file_mime_type = fileMetadata?.mimeType ?? null; @@ -583,14 +706,13 @@ export class OerExtractionService { oer.description = fileMetadata?.description ?? null; oer.audience_uri = ambMetadata.audienceUri; oer.educational_level_uri = ambMetadata.educationalLevelUri; - oer.source = DEFAULT_SOURCE; - oer.event_amb_id = eventAmbId; - oer.event_file_id = fileMetadata?.eventId ?? null; + oer.name = ambMetadata.parsedMetadata.name as string | null; + oer.attribution = ambMetadata.parsedMetadata.author as string | null; } /** - * Fetches and extracts file metadata from a kind 1063 (File) Nostr event. - * Returns null if the event doesn't exist, is the wrong kind, or lookup fails. + * Fetches and extracts file metadata from a kind 1063 (File) Nostr event source. + * Returns null if the event source doesn't exist, is the wrong kind, or lookup fails. * * @param fileEventId - The ID of the file event to fetch * @returns File metadata or null @@ -603,33 +725,39 @@ export class OerExtractionService { } try { - const fileEvent = await this.nostrEventRepository.findOne({ - where: { id: fileEventId }, + // Look up file event in oer_sources by source_identifier + const sourceIdentifier = createNostrSourceIdentifier(fileEventId); + const fileSource = await this.oerSourceRepository.findOne({ + where: { + source_name: SOURCE_NAME_NOSTR, + source_identifier: sourceIdentifier, + }, }); - if (!fileEvent) { + if (!fileSource) { this.logger.debug( - `File event ${fileEventId} not found, will skip setting event_file_id`, + `File event source ${fileEventId} not found, skipping file metadata extraction`, ); return null; } - if (fileEvent.kind !== EVENT_FILE_KIND) { + if (fileSource.source_record_type !== String(EVENT_FILE_KIND)) { this.logger.warn( - `Referenced event ${fileEventId} is not kind ${EVENT_FILE_KIND} (found kind: ${fileEvent.kind})`, + `Referenced source ${fileEventId} is not kind ${EVENT_FILE_KIND} (found record type: ${fileSource.source_record_type})`, ); return null; } - // Extract file metadata fields - const fields = this.extractFileMetadataFromEvent(fileEvent); + // Extract file metadata fields from source_data + const fileEventData = fileSource.source_data as unknown as NostrEventData; + const fields = this.extractFileMetadataFromEventData(fileEventData); return { eventId: fileEventId, ...fields, }; } catch (error) { - this.logger.warn(`Failed to fetch file event ${fileEventId}: ${error}`); + this.logger.warn(`Failed to fetch file source ${fileEventId}: ${error}`); // Return null to continue with extraction even if file event lookup fails return null; } @@ -653,20 +781,22 @@ export class OerExtractionService { const savedOer = await this.oerRepository.save(oer); this.logger.log( - `Successfully ${isUpdate ? 'updated' : 'created'} OER record ${savedOer.id} for event ${oer.event_amb_id}`, + `Successfully ${isUpdate ? 'updated' : 'created'} OER record ${savedOer.id}`, ); return savedOer; } catch (saveError) { - // Handle race condition: another process may have created the same URL between our check and save + // Handle race condition: another process may have created the same URL + source_name between our check and save if (DatabaseErrorClassifier.isDuplicateKeyError(saveError)) { this.logger.debug( - `Duplicate URL ${url} detected for event ${oer.event_amb_id}, returning existing record`, + `Duplicate URL ${url} for source ${oer.source_name} detected, returning existing record`, ); - // Fetch and return the existing record + // Fetch and return the existing record (unique by url + source_name) const duplicateOer = url - ? await this.oerRepository.findOne({ where: { url } }) + ? await this.oerRepository.findOne({ + where: { url, source_name: oer.source_name }, + }) : null; if (duplicateOer) { @@ -675,7 +805,7 @@ export class OerExtractionService { // If we still can't find the record, something is wrong - rethrow this.logger.error( - `Duplicate key error but could not find existing record for URL ${url}`, + `Duplicate key error but could not find existing record for URL ${url} and source ${oer.source_name}`, ); throw saveError; } @@ -684,4 +814,57 @@ export class OerExtractionService { throw saveError; } } + + /** + * Creates or updates OerSource entries for a Nostr event. + * Links the AMB source to the OER and handles file event sources. + * + * @param oer - The OER record to create sources for + * @param ambSource - The OerSource containing the AMB event (kind 30142) + * @param fileEventId - The file event ID referenced by the AMB event (if any) + */ + private async createOrUpdateOerSources( + oer: OpenEducationalResource, + ambSource: OerSource, + fileEventId: string | null, + ): Promise { + const nostrEventData = ambSource.source_data as unknown as NostrEventData; + + try { + // Update the AMB source to link it to the OER and mark as processed + ambSource.oer_id = oer.id; + ambSource.status = 'processed'; + await this.oerSourceRepository.save(ambSource); + this.logger.debug( + `Updated OerSource ${ambSource.id} for AMB event ${nostrEventData.id}`, + ); + + // Link the file event source to the OER if it exists and is different + if (fileEventId && fileEventId !== nostrEventData.id) { + const fileSourceIdentifier = createNostrSourceIdentifier(fileEventId); + const fileSource = await this.oerSourceRepository.findOne({ + where: { + source_name: SOURCE_NAME_NOSTR, + source_identifier: fileSourceIdentifier, + }, + }); + + if (fileSource) { + // Link the file source to the OER and mark as processed + fileSource.oer_id = oer.id; + fileSource.status = 'processed'; + await this.oerSourceRepository.save(fileSource); + this.logger.debug( + `Updated OerSource ${fileSource.id} for file event ${fileEventId}`, + ); + } + } + } catch (error) { + this.logger.error( + `Failed to create/update OerSource for OER ${oer.id}: ${error}`, + error instanceof Error ? error.stack : undefined, + ); + // Don't throw - source creation failure shouldn't fail the entire extraction + } + } } diff --git a/src/oer/services/oer-query.service.ts b/src/oer/services/oer-query.service.ts index 872eefe..c016ec3 100644 --- a/src/oer/services/oer-query.service.ts +++ b/src/oer/services/oer-query.service.ts @@ -4,7 +4,7 @@ import { Repository, Brackets } from 'typeorm'; import type { ExternalOerItemWithSource } from '@edufeed-org/oer-adapter-core'; import { OpenEducationalResource } from '../entities/open-educational-resource.entity'; import { OerQueryDto } from '../dto/oer-query.dto'; -import { OerItem, Creator } from '../dto/oer-response.dto'; +import { OerItem, OerSourceInfo, Creator } from '../dto/oer-response.dto'; import { ImgproxyService } from './imgproxy.service'; import { AdapterSearchService } from '../../adapter'; import { DEFAULT_SOURCE } from '../constants'; @@ -68,7 +68,11 @@ export class OerQueryService { } private async findNostrResources(query: OerQueryDto): Promise { - const qb = this.oerRepository.createQueryBuilder('oer'); + const qb = this.oerRepository + .createQueryBuilder('oer') + .leftJoinAndSelect('oer.sources', 'sources') + // Ensure deterministic ordering of sources by creation date (oldest first) + .addOrderBy('sources.created_at', 'ASC'); // Apply type filter with OR logic (search both MIME type and AMB type) if (query.type) { @@ -158,11 +162,10 @@ export class OerQueryService { }; } - // Maps data for API usage and also adds image proxy urls, source, and creators + // Maps data for API usage and also adds image proxy urls, sources, and creators private mapToOerItem(oer: OpenEducationalResource): OerItem { // Destructure to omit TypeORM relations from API response - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { eventAmb: _eventAmb, eventFile: _eventFile, ...item } = oer; + const { sources: oerSources, ...item } = oer; // Check if this is an image resource const isImage = this.isImageResource(oer); @@ -175,10 +178,18 @@ export class OerQueryService { // Extract creators from AMB metadata const creators = this.extractCreatorsFromAmbMetadata(oer.amb_metadata); + // Map sources to API format (already ordered by created_at ASC from query) + const sources: OerSourceInfo[] = + oerSources?.map((source) => ({ + source_name: source.source_name, + source_identifier: source.source_identifier, + created_at: source.created_at, + })) ?? []; + return { ...item, images: imgProxyUrls, - source: oer.source ?? DEFAULT_SOURCE, + sources, creators, foreign_landing_url: null, }; @@ -186,6 +197,7 @@ export class OerQueryService { /** * Maps an adapter item to the OerItem format. + * External adapter items have a single source, which we convert to a sources array. */ private mapAdapterItemToOerItem(item: ExternalOerItemWithSource): OerItem { return { @@ -203,14 +215,21 @@ export class OerQueryService { file_dim: item.file_dim, file_alt: item.file_alt, images: item.images, - source: item.source, + // source_name indicates the authoritative source for this OER + source_name: item.source, + // Convert single source to sources array format + sources: [ + { + source_name: item.source, + source_identifier: item.id, + created_at: new Date(), + }, + ], creators: item.creators, // Fields that are specific to Nostr/database records - set to null for adapters amb_metadata: null, audience_uri: null, educational_level_uri: null, - event_amb_id: null, - event_file_id: null, created_at: null, updated_at: null, }; diff --git a/src/oer/types/extraction.types.ts b/src/oer/types/extraction.types.ts index 6481022..f5683c1 100644 --- a/src/oer/types/extraction.types.ts +++ b/src/oer/types/extraction.types.ts @@ -20,7 +20,7 @@ export interface AmbMetadata { license: LicenseInfo; /** Keywords from 't' tags */ keywords: string[] | null; - /** File event ID reference from 'e' tag with 'file' marker */ + /** File event ID reference from first 'e' tag (kind 1063 event) */ fileEventId: string | null; } diff --git a/test/event-deletion.e2e-spec.ts b/test/event-deletion.e2e-spec.ts index f8b06ee..58cb267 100644 --- a/test/event-deletion.e2e-spec.ts +++ b/test/event-deletion.e2e-spec.ts @@ -2,22 +2,28 @@ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import { Repository } from 'typeorm'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { NostrEvent } from '../src/nostr/entities/nostr-event.entity'; import { OpenEducationalResource } from '../src/oer/entities/open-educational-resource.entity'; +import { OerSource } from '../src/oer/entities/oer-source.entity'; import { EventDeletionService } from '../src/nostr/services/event-deletion.service'; import { OerExtractionService } from '../src/oer/services/oer-extraction.service'; +import { NostrEventDatabaseService } from '../src/nostr/services/nostr-event-database.service'; import { NostrClientService } from '../src/nostr/services/nostr-client.service'; import { AppModule } from '../src/app.module'; import { ThrottlerGuard } from '@nestjs/throttler'; import type { Event } from 'nostr-tools/core'; -import { nostrEventFixtures, eventFactoryHelpers } from './fixtures'; +import { + nostrEventFixtures, + eventFactoryHelpers, + NostrEventData, +} from './fixtures'; describe('Event Deletion Integration Tests (e2e)', () => { let app: INestApplication; let eventDeletionService: EventDeletionService; let oerExtractionService: OerExtractionService; - let nostrEventRepository: Repository; + let nostrEventDatabaseService: NostrEventDatabaseService; let oerRepository: Repository; + let oerSourceRepository: Repository; // Mock NostrClientService to prevent real relay connections const mockNostrClientService = { @@ -51,21 +57,53 @@ describe('Event Deletion Integration Tests (e2e)', () => { moduleFixture.get(EventDeletionService); oerExtractionService = moduleFixture.get(OerExtractionService); - nostrEventRepository = moduleFixture.get>( - getRepositoryToken(NostrEvent), + nostrEventDatabaseService = moduleFixture.get( + NostrEventDatabaseService, ); oerRepository = moduleFixture.get>( getRepositoryToken(OpenEducationalResource), ); + oerSourceRepository = moduleFixture.get>( + getRepositoryToken(OerSource), + ); + }); + + /** + * Helper to convert NostrEventData to nostr-tools Event format + */ + const toNostrEvent = (data: NostrEventData): Event => ({ + id: data.id, + kind: data.kind, + pubkey: data.pubkey, + created_at: data.created_at, + content: data.content, + tags: data.tags, + sig: 'test-signature', }); + /** + * Helper to save an event and return its OerSource + */ + const saveEvent = async ( + data: NostrEventData, + relayUrl = 'wss://relay.example.com', + ): Promise => { + const event = toNostrEvent(data); + const result = await nostrEventDatabaseService.saveEvent(event, relayUrl); + if (!result.success) { + throw new Error(`Failed to save event: ${result.reason}`); + } + return result.source; + }; + afterAll(async () => { await app.close(); }); beforeEach(async () => { - // Clear existing test data - await oerRepository.clear(); + // Clear existing test data using query builder (TRUNCATE doesn't work with FK constraints) + await oerSourceRepository.createQueryBuilder().delete().execute(); + await oerRepository.createQueryBuilder().delete().execute(); }); describe('AMB Event Deletion', () => { @@ -73,15 +111,22 @@ describe('Event Deletion Integration Tests (e2e)', () => { const pubkey = 'test-pubkey-amb'; // Use minimal fixture with just ID override - const ambEvent = nostrEventRepository.create( - nostrEventFixtures.ambMinimal({ id: 'amb-event-1', pubkey }), - ); - await nostrEventRepository.save(ambEvent); + const ambEventData = nostrEventFixtures.ambMinimal({ + id: 'amb-event-1', + pubkey, + }); + await saveEvent(ambEventData); // Extract OER - const oer = await oerExtractionService.extractOerFromEvent(ambEvent); + const oer = await oerExtractionService.extractOerFromEvent(ambEventData); expect(oer).toBeDefined(); - expect(oer.event_amb_id).toBe('amb-event-1'); + + // Verify OerSource was created + const sources = await oerSourceRepository.find({ + where: { oer_id: oer.id }, + }); + expect(sources).toHaveLength(1); + expect(sources[0].source_identifier).toBe('event:amb-event-1'); // Verify OER exists let oerCount = await oerRepository.count(); @@ -97,9 +142,8 @@ describe('Event Deletion Integration Tests (e2e)', () => { await eventDeletionService.processDeleteEvent(deleteEvent); // Verify AMB event was deleted - const ambEventAfter = await nostrEventRepository.findOne({ - where: { id: 'amb-event-1' }, - }); + const ambEventAfter = + await nostrEventDatabaseService.findEventById('amb-event-1'); expect(ambEventAfter).toBeNull(); // Verify OER was deleted @@ -109,16 +153,14 @@ describe('Event Deletion Integration Tests (e2e)', () => { it('should not delete AMB event if pubkey does not match', async () => { // Create AMB event with one pubkey - const ambEvent = nostrEventRepository.create( - nostrEventFixtures.ambMinimal({ - id: 'amb-event-2', - pubkey: 'original-pubkey', - }), - ); - await nostrEventRepository.save(ambEvent); + const ambEventData = nostrEventFixtures.ambMinimal({ + id: 'amb-event-2', + pubkey: 'original-pubkey', + }); + await saveEvent(ambEventData); // Extract OER - const oer = await oerExtractionService.extractOerFromEvent(ambEvent); + const oer = await oerExtractionService.extractOerFromEvent(ambEventData); expect(oer).toBeDefined(); // Create deletion event with different pubkey @@ -131,9 +173,8 @@ describe('Event Deletion Integration Tests (e2e)', () => { await eventDeletionService.processDeleteEvent(deleteEvent); // Verify AMB event still exists - const ambEventAfter = await nostrEventRepository.findOne({ - where: { id: 'amb-event-2' }, - }); + const ambEventAfter = + await nostrEventDatabaseService.findEventById('amb-event-2'); expect(ambEventAfter).not.toBeNull(); // Verify OER still exists @@ -147,33 +188,41 @@ describe('Event Deletion Integration Tests (e2e)', () => { const pubkey = 'test-pubkey-file'; // Use file fixture with ID override - const fileEvent = nostrEventRepository.create( - nostrEventFixtures.fileComplete({ id: 'file-event-1', pubkey }), - ); - await nostrEventRepository.save(fileEvent); + const fileEventData = nostrEventFixtures.fileComplete({ + id: 'file-event-1', + pubkey, + }); + await saveEvent(fileEventData); // Create AMB event that references the file - const ambEvent = nostrEventRepository.create( - nostrEventFixtures.ambMinimal({ - id: 'amb-event-3', - pubkey, - tags: [ - ['d', 'https://example.edu/diagram.png'], - ['type', 'LearningResource'], - ['e', 'file-event-1', 'wss://relay.example.com', 'file'], - ], - }), - ); - await nostrEventRepository.save(ambEvent); + const ambEventData = nostrEventFixtures.ambMinimal({ + id: 'amb-event-3', + pubkey, + tags: [ + ['d', 'https://example.edu/diagram.png'], + ['type', 'LearningResource'], + ['e', 'file-event-1', 'wss://relay.example.com', 'file'], + ], + }); + await saveEvent(ambEventData); // Extract OER - const oer = await oerExtractionService.extractOerFromEvent(ambEvent); + const oer = await oerExtractionService.extractOerFromEvent(ambEventData); expect(oer).toBeDefined(); - expect(oer.event_file_id).toBe('file-event-1'); expect(oer.file_mime_type).toBe('image/png'); expect(oer.file_dim).toBe('1920x1080'); expect(oer.file_size).toBe(245680); + // Verify OerSource entries were created (one for AMB, one for file) + const sources = await oerSourceRepository.find({ + where: { oer_id: oer.id }, + }); + expect(sources.length).toBeGreaterThanOrEqual(1); + const hasFileSource = sources.some( + (s) => s.source_identifier === 'event:file-event-1', + ); + expect(hasFileSource).toBe(true); + // Create and process deletion event for file const deleteEvent: Event = eventFactoryHelpers.createDeleteEvent( 'file-event-1', @@ -184,16 +233,23 @@ describe('Event Deletion Integration Tests (e2e)', () => { await eventDeletionService.processDeleteEvent(deleteEvent); // Verify File event was deleted - const fileEventAfter = await nostrEventRepository.findOne({ - where: { id: 'file-event-1' }, - }); + const fileEventAfter = + await nostrEventDatabaseService.findEventById('file-event-1'); expect(fileEventAfter).toBeNull(); - // Verify OER still exists with file link and metadata nullified + // Verify OER still exists with file metadata nullified const oerAfter = await oerRepository.findOne({ where: { id: oer.id } }); expect(oerAfter).not.toBeNull(); - expect(oerAfter?.event_amb_id).toBe('amb-event-3'); // Still linked to AMB - expect(oerAfter?.event_file_id).toBeNull(); // File link removed by FK constraint + + // Verify AMB source still exists but file source might have been deleted + const sourcesAfter = await oerSourceRepository.find({ + where: { oer_id: oer.id }, + }); + const hasAmbSource = sourcesAfter.some( + (s) => s.source_identifier === 'event:amb-event-3', + ); + expect(hasAmbSource).toBe(true); // AMB source should still exist + // File metadata is nullified when file event is deleted expect(oerAfter?.file_mime_type).toBeNull(); expect(oerAfter?.file_dim).toBeNull(); @@ -207,33 +263,29 @@ describe('Event Deletion Integration Tests (e2e)', () => { const pubkey = 'test-pubkey-multi'; // Create multiple AMB events using fixtures with different URLs - const ambEvent1 = nostrEventRepository.create( - nostrEventFixtures.ambMinimal({ - id: 'amb-event-4', - pubkey, - tags: [ - ['d', 'https://example.edu/resource4.pdf'], - ['type', 'LearningResource'], - ], - }), - ); - await nostrEventRepository.save(ambEvent1); - - const ambEvent2 = nostrEventRepository.create( - nostrEventFixtures.ambMinimal({ - id: 'amb-event-5', - pubkey, - tags: [ - ['d', 'https://example.edu/resource5.pdf'], - ['type', 'LearningResource'], - ], - }), - ); - await nostrEventRepository.save(ambEvent2); + const ambEventData1 = nostrEventFixtures.ambMinimal({ + id: 'amb-event-4', + pubkey, + tags: [ + ['d', 'https://example.edu/resource4.pdf'], + ['type', 'LearningResource'], + ], + }); + await saveEvent(ambEventData1); + + const ambEventData2 = nostrEventFixtures.ambMinimal({ + id: 'amb-event-5', + pubkey, + tags: [ + ['d', 'https://example.edu/resource5.pdf'], + ['type', 'LearningResource'], + ], + }); + await saveEvent(ambEventData2); // Extract OERs - await oerExtractionService.extractOerFromEvent(ambEvent1); - await oerExtractionService.extractOerFromEvent(ambEvent2); + await oerExtractionService.extractOerFromEvent(ambEventData1); + await oerExtractionService.extractOerFromEvent(ambEventData2); let oerCount = await oerRepository.count(); expect(oerCount).toBe(2); @@ -248,12 +300,10 @@ describe('Event Deletion Integration Tests (e2e)', () => { await eventDeletionService.processDeleteEvent(deleteEvent); // Verify both AMB events were deleted - const amb1After = await nostrEventRepository.findOne({ - where: { id: 'amb-event-4' }, - }); - const amb2After = await nostrEventRepository.findOne({ - where: { id: 'amb-event-5' }, - }); + const amb1After = + await nostrEventDatabaseService.findEventById('amb-event-4'); + const amb2After = + await nostrEventDatabaseService.findEventById('amb-event-5'); expect(amb1After).toBeNull(); expect(amb2After).toBeNull(); @@ -283,22 +333,22 @@ describe('Event Deletion Integration Tests (e2e)', () => { const pubkey = 'test-pubkey-rollback'; // Create AMB event using fixture - const ambEvent = nostrEventRepository.create( - nostrEventFixtures.ambMinimal({ id: 'amb-event-6', pubkey }), - ); - await nostrEventRepository.save(ambEvent); + const ambEventData = nostrEventFixtures.ambMinimal({ + id: 'amb-event-6', + pubkey, + }); + await saveEvent(ambEventData); // Extract OER - await oerExtractionService.extractOerFromEvent(ambEvent); + await oerExtractionService.extractOerFromEvent(ambEventData); // Mock a database error by closing the connection temporarily // This test is more conceptual - in reality we'd need to inject a failing manager // For now, we just verify that the service has transaction handling // Verify event and OER exist before deletion attempt - const eventBefore = await nostrEventRepository.findOne({ - where: { id: 'amb-event-6' }, - }); + const eventBefore = + await nostrEventDatabaseService.findEventById('amb-event-6'); const oerCountBefore = await oerRepository.count(); expect(eventBefore).not.toBeNull(); expect(oerCountBefore).toBe(1); diff --git a/test/fixtures/eventFactory.ts b/test/fixtures/eventFactory.ts index 95a5bd8..9862caf 100644 --- a/test/fixtures/eventFactory.ts +++ b/test/fixtures/eventFactory.ts @@ -2,15 +2,30 @@ * Event Factories and Fixtures * * This module provides factories for creating Nostr event test data: - * - NostrEventFactory: Create NostrEvent entities + * - NostrEventFactory: Create test event data for use with OerSource * - EventFactory: Create nostr-tools Event objects * - Pre-configured fixtures for common scenarios * - Helper functions for specific event types */ -import { NostrEvent } from '../../src/nostr/entities/nostr-event.entity'; import type { Event } from 'nostr-tools/core'; +/** + * Represents a Nostr event structure for testing. + * This mirrors the structure stored in OerSource.source_data. + */ +export interface NostrEventData { + id: string; + kind: number; + pubkey: string; + created_at: number; + content: string; + tags: string[][]; + raw_event?: Record; + relay_url?: string | null; + ingested_at?: Date; +} + // Nostr Event Fixtures import ambCompleteJson from './nostr-events/amb-complete.json'; import ambMinimalJson from './nostr-events/amb-minimal.json'; @@ -18,29 +33,30 @@ import ambWithUrisJson from './nostr-events/amb-with-uris.json'; import fileCompleteJson from './nostr-events/file-complete.json'; /** - * Factory for creating NostrEvent entities with type-safe defaults + * Factory for creating NostrEventData with type-safe defaults. + * This data can be stored in OerSource.source_data. */ export class NostrEventFactory { - private static readonly defaults: Readonly = { + private static readonly defaults: Readonly = { id: 'default-id', kind: 1, pubkey: 'default-pubkey', created_at: 1234567890, content: '', tags: [], - raw_event: {}, + raw_event: undefined, relay_url: 'wss://relay.example.com', ingested_at: new Date(), }; /** - * Create a NostrEvent with defaults and overrides + * Create a NostrEventData with defaults and overrides * Uses spread operator to merge defaults with custom values */ static create( - base?: Partial, - overrides?: Partial, - ): NostrEvent { + base?: Partial, + overrides?: Partial, + ): NostrEventData { const merged = { ...this.defaults, ...base, @@ -60,9 +76,9 @@ export class NostrEventFactory { */ static fromJson( json: Record, - overrides?: Partial, - ): NostrEvent { - return this.create(json as Partial, overrides); + overrides?: Partial, + ): NostrEventData { + return this.create(json as Partial, overrides); } } @@ -103,25 +119,25 @@ export const nostrEventFixtures = { /** * Complete AMB event with all fields populated */ - ambComplete: (overrides?: Partial): NostrEvent => + ambComplete: (overrides?: Partial): NostrEventData => NostrEventFactory.fromJson(ambCompleteJson, overrides), /** * Minimal AMB event with only required fields */ - ambMinimal: (overrides?: Partial): NostrEvent => + ambMinimal: (overrides?: Partial): NostrEventData => NostrEventFactory.fromJson(ambMinimalJson, overrides), /** * AMB event with educational level and audience URIs */ - ambWithUris: (overrides?: Partial): NostrEvent => + ambWithUris: (overrides?: Partial): NostrEventData => NostrEventFactory.fromJson(ambWithUrisJson, overrides), /** * Complete file event with all metadata */ - fileComplete: (overrides?: Partial): NostrEvent => + fileComplete: (overrides?: Partial): NostrEventData => NostrEventFactory.fromJson(fileCompleteJson, overrides), }; @@ -132,7 +148,7 @@ export const eventFactoryHelpers = { /** * Create an AMB event (kind 30142) with sensible defaults */ - createAmbEvent: (overrides?: Partial): NostrEvent => { + createAmbEvent: (overrides?: Partial): NostrEventData => { return NostrEventFactory.create( { kind: 30142, @@ -148,7 +164,7 @@ export const eventFactoryHelpers = { /** * Create a File event (kind 1063) with sensible defaults */ - createFileEvent: (overrides?: Partial): NostrEvent => { + createFileEvent: (overrides?: Partial): NostrEventData => { return NostrEventFactory.create( { kind: 1063, diff --git a/test/fixtures/index.ts b/test/fixtures/index.ts index f617073..878986a 100644 --- a/test/fixtures/index.ts +++ b/test/fixtures/index.ts @@ -18,6 +18,7 @@ export { EventFactory, nostrEventFixtures, eventFactoryHelpers, + type NostrEventData, } from './eventFactory'; // Re-export everything from OER factories diff --git a/test/fixtures/nostr-events/amb-complete.json b/test/fixtures/nostr-events/amb-complete.json index dc4937b..62b343b 100644 --- a/test/fixtures/nostr-events/amb-complete.json +++ b/test/fixtures/nostr-events/amb-complete.json @@ -16,6 +16,5 @@ ["dateCreated", "2024-01-15T10:30:00Z"], ["datePublished", "2024-01-20T14:00:00Z"], ["e", "file-event-complete-fixture", "wss://relay.example.com", "file"] - ], - "raw_event": {} + ] } diff --git a/test/fixtures/nostr-events/amb-minimal.json b/test/fixtures/nostr-events/amb-minimal.json index 6b01c09..08b461b 100644 --- a/test/fixtures/nostr-events/amb-minimal.json +++ b/test/fixtures/nostr-events/amb-minimal.json @@ -7,6 +7,5 @@ "tags": [ ["d", "https://example.edu/resource.pdf"], ["type", "LearningResource"] - ], - "raw_event": {} + ] } diff --git a/test/fixtures/nostr-events/amb-with-dates.json b/test/fixtures/nostr-events/amb-with-dates.json index 0a5b231..223b06c 100644 --- a/test/fixtures/nostr-events/amb-with-dates.json +++ b/test/fixtures/nostr-events/amb-with-dates.json @@ -13,6 +13,5 @@ ["dateCreated", "2024-02-15T10:00:00Z"], ["datePublished", "2024-02-20T12:00:00Z"], ["dateModified", "2024-02-25T14:00:00Z"] - ], - "raw_event": {} + ] } diff --git a/test/fixtures/nostr-events/amb-with-uris.json b/test/fixtures/nostr-events/amb-with-uris.json index 63c0ca0..64b4dde 100644 --- a/test/fixtures/nostr-events/amb-with-uris.json +++ b/test/fixtures/nostr-events/amb-with-uris.json @@ -11,6 +11,5 @@ ["audience:id", "http://purl.org/dcx/lrmi-vocabs/educationalAudienceRole/student"], ["audience:prefLabel:en", "Student"], ["type", "LearningResource"] - ], - "raw_event": {} + ] } diff --git a/test/fixtures/nostr-events/file-complete.json b/test/fixtures/nostr-events/file-complete.json index 9534ce1..01cc0c4 100644 --- a/test/fixtures/nostr-events/file-complete.json +++ b/test/fixtures/nostr-events/file-complete.json @@ -9,6 +9,5 @@ ["dim", "1920x1080"], ["size", "245680"], ["alt", "Photosynthesis diagram"] - ], - "raw_event": {} + ] } diff --git a/test/fixtures/nostr-events/file-with-description.json b/test/fixtures/nostr-events/file-with-description.json index 3c6b6cf..71fc725 100644 --- a/test/fixtures/nostr-events/file-with-description.json +++ b/test/fixtures/nostr-events/file-with-description.json @@ -8,6 +8,5 @@ ["description", "Tag description takes priority"], ["m", "image/jpeg"], ["dim", "800x600"] - ], - "raw_event": {} + ] } diff --git a/test/fixtures/oerFactory.ts b/test/fixtures/oerFactory.ts index 00463c3..ce0a374 100644 --- a/test/fixtures/oerFactory.ts +++ b/test/fixtures/oerFactory.ts @@ -8,6 +8,7 @@ */ import { OpenEducationalResource } from '../../src/oer/entities/open-educational-resource.entity'; +import { SOURCE_NAME_NOSTR } from '../../src/oer/constants'; // OER Fixtures import oerQueryFixturesJson from './oer/oer-query-fixtures.json'; @@ -44,9 +45,15 @@ export class OerFactory { const now = new Date(); const result = { + // Primary key - omit by default to let DB generate UUID + ...(base?.id ? { id: base.id } : {}), + // Default URL (nullable in entity but commonly needed in tests) url: base?.url ?? 'https://example.edu/default.pdf', + // source_name identifies the authoritative source for this OER + source_name: base?.source_name ?? SOURCE_NAME_NOSTR, + // Nullable fields - use nullish coalescing to preserve false/0 values license_uri: base?.license_uri ?? null, free_to_use: base?.free_to_use ?? null, @@ -56,16 +63,14 @@ export class OerFactory { file_dim: base?.file_dim ?? null, file_size: base?.file_size ?? null, file_alt: base?.file_alt ?? null, + name: base?.name ?? null, description: base?.description ?? null, + attribution: base?.attribution ?? null, audience_uri: base?.audience_uri ?? null, educational_level_uri: base?.educational_level_uri ?? null, - source: base?.source ?? null, - event_amb_id: base?.event_amb_id ?? null, - event_file_id: base?.event_file_id ?? null, // Relations (nullable) - eventAmb: base?.eventAmb ?? null, - eventFile: base?.eventFile ?? null, + sources: base?.sources ?? [], // Required timestamps (not nullable in entity) created_at: base?.created_at ?? now, @@ -157,8 +162,6 @@ const baseOerData = { file_size: 245680, file_alt: 'Photosynthesis diagram', description: 'A diagram showing photosynthesis', - event_amb_id: 'event123', - event_file_id: 'file123', } as Partial, /** @@ -171,7 +174,6 @@ const baseOerData = { d: 'https://example.edu/resource.pdf', type: 'LearningResource', }, - event_amb_id: 'event456', } as Partial, /** @@ -189,7 +191,6 @@ const baseOerData = { file_size: 100000, file_alt: 'Existing alt text', description: 'Existing description', - event_amb_id: 'event-old', created_at: new Date('2020-01-01'), updated_at: new Date('2020-01-01'), } as Partial, @@ -253,7 +254,6 @@ export const oerFactoryHelpers = { id: 'oer-no-dates', url: 'https://example.edu/resource.png', amb_metadata: {}, - event_amb_id: null, created_at: new Date('2020-01-01'), updated_at: new Date('2020-01-01'), }, @@ -362,7 +362,6 @@ export const oerFactoryHelpers = { free_to_use: true, description: 'Test resource', keywords: ['test', 'education'], - event_amb_id: 'event123', amb_metadata: { dateCreated: '2024-01-01T00:00:00Z', datePublished: '2024-01-01T00:00:00Z', @@ -377,8 +376,8 @@ export const oerFactoryHelpers = { overrides, ); return { - id: '123', ...oer, + id: '123', } as OpenEducationalResource; }, }; diff --git a/test/oer-api.e2e-spec.ts b/test/oer-api.e2e-spec.ts index b2846a1..b56e04a 100644 --- a/test/oer-api.e2e-spec.ts +++ b/test/oer-api.e2e-spec.ts @@ -5,6 +5,7 @@ import { AppModule } from '../src/app.module'; import { Repository } from 'typeorm'; import { getRepositoryToken } from '@nestjs/typeorm'; import { OpenEducationalResource } from '../src/oer/entities/open-educational-resource.entity'; +import { OerSource } from '../src/oer/entities/oer-source.entity'; import { NostrClientService } from '../src/nostr/services/nostr-client.service'; import { ThrottlerGuard } from '@nestjs/throttler'; import { OerFactory, testDataGenerators } from './fixtures'; @@ -12,6 +13,7 @@ import { OerFactory, testDataGenerators } from './fixtures'; describe('OER API (e2e)', () => { let app: INestApplication; let oerRepository: Repository; + let oerSourceRepository: Repository; // Mock NostrClientService to prevent real relay connections const mockNostrClientService = { @@ -44,6 +46,9 @@ describe('OER API (e2e)', () => { oerRepository = moduleFixture.get>( getRepositoryToken(OpenEducationalResource), ); + oerSourceRepository = moduleFixture.get>( + getRepositoryToken(OerSource), + ); }); afterAll(async () => { @@ -51,8 +56,9 @@ describe('OER API (e2e)', () => { }); beforeEach(async () => { - // Clear existing test data - await oerRepository.clear(); + // Clear existing test data using query builder (TRUNCATE doesn't work with FK constraints) + await oerSourceRepository.createQueryBuilder().delete().execute(); + await oerRepository.createQueryBuilder().delete().execute(); }); describe('GET /api/v1/oer', () => { @@ -452,13 +458,11 @@ describe('OER API (e2e)', () => { expect(response.body.error).toBe('Bad Request'); }); - it('should include event IDs in response', async () => { + it('should include sources array and source_name in response', async () => { await oerRepository.save([ oerRepository.create( OerFactory.create({ url: 'https://example.edu/resource.png', - event_amb_id: null, - event_file_id: null, }), ), ]); @@ -467,8 +471,10 @@ describe('OER API (e2e)', () => { .get('/api/v1/oer') .expect(200); - expect(response.body.data[0]).toHaveProperty('event_amb_id'); - expect(response.body.data[0]).toHaveProperty('event_file_id'); + expect(response.body.data[0]).toHaveProperty('sources'); + expect(response.body.data[0]).toHaveProperty('source_name'); + expect(Array.isArray(response.body.data[0].sources)).toBe(true); + expect(response.body.data[0].source_name).toBe('nostr'); }); it('should include extended fields in API response', async () => { diff --git a/test/oer-extraction.e2e-spec.ts b/test/oer-extraction.e2e-spec.ts index 2be92c9..b523ec4 100644 --- a/test/oer-extraction.e2e-spec.ts +++ b/test/oer-extraction.e2e-spec.ts @@ -2,19 +2,22 @@ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import { Repository } from 'typeorm'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { NostrEvent } from '../src/nostr/entities/nostr-event.entity'; import { OpenEducationalResource } from '../src/oer/entities/open-educational-resource.entity'; +import { OerSource } from '../src/oer/entities/oer-source.entity'; import { OerExtractionService } from '../src/oer/services/oer-extraction.service'; +import { NostrEventDatabaseService } from '../src/nostr/services/nostr-event-database.service'; import { NostrClientService } from '../src/nostr/services/nostr-client.service'; import { AppModule } from '../src/app.module'; import { ThrottlerGuard } from '@nestjs/throttler'; -import { nostrEventFixtures } from './fixtures'; +import type { Event } from 'nostr-tools/core'; +import { nostrEventFixtures, NostrEventData } from './fixtures'; describe('OER Extraction Integration Tests (e2e)', () => { let app: INestApplication; let oerExtractionService: OerExtractionService; - let nostrEventRepository: Repository; + let nostrEventDatabaseService: NostrEventDatabaseService; let oerRepository: Repository; + let oerSourceRepository: Repository; // Mock NostrClientService to prevent real relay connections const mockNostrClientService = { @@ -46,37 +49,65 @@ describe('OER Extraction Integration Tests (e2e)', () => { oerExtractionService = moduleFixture.get(OerExtractionService); - nostrEventRepository = moduleFixture.get>( - getRepositoryToken(NostrEvent), + nostrEventDatabaseService = moduleFixture.get( + NostrEventDatabaseService, ); oerRepository = moduleFixture.get>( getRepositoryToken(OpenEducationalResource), ); + oerSourceRepository = moduleFixture.get>( + getRepositoryToken(OerSource), + ); + }); + + /** + * Helper to convert NostrEventData to nostr-tools Event format + */ + const toNostrEvent = (data: NostrEventData): Event => ({ + id: data.id, + kind: data.kind, + pubkey: data.pubkey, + created_at: data.created_at, + content: data.content, + tags: data.tags, + sig: 'test-signature', }); + /** + * Helper to save an event and return its OerSource + */ + const saveEvent = async ( + data: NostrEventData, + relayUrl = 'wss://relay.example.com', + ): Promise => { + const event = toNostrEvent(data); + const result = await nostrEventDatabaseService.saveEvent(event, relayUrl); + if (!result.success) { + throw new Error(`Failed to save event: ${result.reason}`); + } + return result.source; + }; + afterAll(async () => { await app.close(); }); beforeEach(async () => { - // Clear existing test data - await oerRepository.clear(); + // Clear existing test data using query builder (TRUNCATE doesn't work with FK constraints) + await oerSourceRepository.createQueryBuilder().delete().execute(); + await oerRepository.createQueryBuilder().delete().execute(); }); it('should create OER record from kind 30142 (AMB) event with complete data', async () => { // Use pre-configured fixtures - they already reference each other correctly - const fileEvent = nostrEventRepository.create( - nostrEventFixtures.fileComplete(), - ); - await nostrEventRepository.save(fileEvent); + const fileEventData = nostrEventFixtures.fileComplete(); + await saveEvent(fileEventData); - const ambEvent = nostrEventRepository.create( - nostrEventFixtures.ambComplete(), - ); - await nostrEventRepository.save(ambEvent); + const ambEventData = nostrEventFixtures.ambComplete(); + await saveEvent(ambEventData); // Extract OER - const oer = await oerExtractionService.extractOerFromEvent(ambEvent); + const oer = await oerExtractionService.extractOerFromEvent(ambEventData); // Verify OER record was created with complete data from fixtures expect(oer).toBeDefined(); @@ -91,8 +122,7 @@ describe('OER Extraction Integration Tests (e2e)', () => { expect(oer.file_size).toBe(245680); expect(oer.file_alt).toContain('diagram'); expect(oer.description).toContain('diagram'); - expect(oer.event_amb_id).toBe('amb-event-complete-fixture'); - expect(oer.event_file_id).toBe('file-event-complete-fixture'); + expect(oer.keywords).toEqual(['photosynthesis', 'biology']); expect(oer.amb_metadata).toBeDefined(); expect(oer.amb_metadata).toHaveProperty('learningResourceType'); @@ -104,12 +134,12 @@ describe('OER Extraction Integration Tests (e2e)', () => { }); it('should create OER record with minimal data when fields are missing', async () => { - const ambEvent = nostrEventRepository.create( - nostrEventFixtures.ambMinimal({ id: 'amb-event-minimal' }), - ); - await nostrEventRepository.save(ambEvent); + const ambEventData = nostrEventFixtures.ambMinimal({ + id: 'amb-event-minimal', + }); + await saveEvent(ambEventData); - const oer = await oerExtractionService.extractOerFromEvent(ambEvent); + const oer = await oerExtractionService.extractOerFromEvent(ambEventData); expect(oer).toBeDefined(); expect(oer.url).toBe('https://example.edu/resource.pdf'); @@ -120,29 +150,26 @@ describe('OER Extraction Integration Tests (e2e)', () => { expect(oer.file_size).toBeNull(); expect(oer.file_alt).toBeNull(); expect(oer.description).toBeNull(); - expect(oer.event_amb_id).toBe('amb-event-minimal'); - expect(oer.event_file_id).toBeNull(); + expect(oer.keywords).toBeNull(); expect(oer.amb_metadata).toBeDefined(); }); it('should handle missing file event gracefully', async () => { - const ambEvent = nostrEventRepository.create( - nostrEventFixtures.ambMinimal({ - id: 'amb-event-missing-file', - tags: [ - ['d', 'https://example.edu/resource.png'], - ['type', 'LearningResource'], - ['e', 'non-existent-file-event', 'wss://relay.example.com', 'file'], - ], - }), - ); - await nostrEventRepository.save(ambEvent); + const ambEventData = nostrEventFixtures.ambMinimal({ + id: 'amb-event-missing-file', + tags: [ + ['d', 'https://example.edu/resource.png'], + ['type', 'LearningResource'], + ['e', 'non-existent-file-event', 'wss://relay.example.com', 'file'], + ], + }); + await saveEvent(ambEventData); - const oer = await oerExtractionService.extractOerFromEvent(ambEvent); + const oer = await oerExtractionService.extractOerFromEvent(ambEventData); expect(oer).toBeDefined(); - expect(oer.event_file_id).toBeNull(); // Should be null when file event doesn't exist + expect(oer.file_mime_type).toBeNull(); expect(oer.file_dim).toBeNull(); expect(oer.file_size).toBeNull(); @@ -150,46 +177,40 @@ describe('OER Extraction Integration Tests (e2e)', () => { }); it('should verify foreign key relationships work correctly', async () => { - const fileEvent = nostrEventRepository.create( - nostrEventFixtures.fileComplete({ id: 'file-event-fk' }), - ); - await nostrEventRepository.save(fileEvent); - - const ambEvent = nostrEventRepository.create( - nostrEventFixtures.ambMinimal({ - id: 'amb-event-fk', - tags: [ - ['d', 'https://example.edu/image.jpg'], - ['type', 'LearningResource'], - ['e', 'file-event-fk', 'wss://relay.example.com', 'file'], - ], - }), - ); - await nostrEventRepository.save(ambEvent); + const fileEventData = nostrEventFixtures.fileComplete({ + id: 'file-event-fk', + }); + await saveEvent(fileEventData); + + const ambEventData = nostrEventFixtures.ambMinimal({ + id: 'amb-event-fk', + tags: [ + ['d', 'https://example.edu/image.jpg'], + ['type', 'LearningResource'], + ['e', 'file-event-fk', 'wss://relay.example.com', 'file'], + ], + }); + await saveEvent(ambEventData); - const oer = await oerExtractionService.extractOerFromEvent(ambEvent); + const oer = await oerExtractionService.extractOerFromEvent(ambEventData); // Load with relations to verify foreign keys const savedOer = await oerRepository.findOne({ where: { id: oer.id }, - relations: ['eventAmb', 'eventFile'], + relations: ['sources'], }); expect(savedOer).toBeDefined(); - expect(savedOer?.eventAmb?.id).toBe('amb-event-fk'); - expect(savedOer?.eventFile?.id).toBe('file-event-fk'); }); it('should not extract OER for non-30142 events', async () => { - const kind1Event = nostrEventRepository.create( - nostrEventFixtures.ambMinimal({ - id: 'kind-1-event', - kind: 1, - content: 'Just a regular note', - tags: [], - }), - ); - await nostrEventRepository.save(kind1Event); + const kind1EventData = nostrEventFixtures.ambMinimal({ + id: 'kind-1-event', + kind: 1, + content: 'Just a regular note', + tags: [], + }); + await saveEvent(kind1EventData); expect(oerExtractionService.shouldExtractOer(1)).toBe(false); @@ -198,23 +219,21 @@ describe('OER Extraction Integration Tests (e2e)', () => { }); it('should parse nested JSON metadata from colon-separated tags', async () => { - const ambEvent = nostrEventRepository.create( - nostrEventFixtures.ambMinimal({ - id: 'amb-event-nested', - tags: [ - ['d', 'https://example.edu/resource'], - ['learningResourceType:id', 'http://w3id.org/kim/hcrt/video'], - ['learningResourceType:prefLabel:en', 'Video'], - ['learningResourceType:prefLabel:de', 'Video'], - ['about:id', 'http://example.org/topics/math'], - ['about:prefLabel:en', 'Mathematics'], - ['type', 'LearningResource'], - ], - }), - ); - await nostrEventRepository.save(ambEvent); + const ambEventData = nostrEventFixtures.ambMinimal({ + id: 'amb-event-nested', + tags: [ + ['d', 'https://example.edu/resource'], + ['learningResourceType:id', 'http://w3id.org/kim/hcrt/video'], + ['learningResourceType:prefLabel:en', 'Video'], + ['learningResourceType:prefLabel:de', 'Video'], + ['about:id', 'http://example.org/topics/math'], + ['about:prefLabel:en', 'Mathematics'], + ['type', 'LearningResource'], + ], + }); + await saveEvent(ambEventData); - const oer = await oerExtractionService.extractOerFromEvent(ambEvent); + const oer = await oerExtractionService.extractOerFromEvent(ambEventData); expect(oer.amb_metadata).toBeDefined(); expect(oer.amb_metadata).toHaveProperty('learningResourceType'); @@ -236,22 +255,19 @@ describe('OER Extraction Integration Tests (e2e)', () => { describe('URL uniqueness and upsert behavior', () => { it('should create new OER when URL is unique', async () => { - const ambEvent = nostrEventRepository.create( - nostrEventFixtures.ambMinimal({ - id: 'unique-event', - tags: [ - ['d', 'https://example.edu/unique-resource.png'], - ['type', 'Image'], - ], - }), - ); - await nostrEventRepository.save(ambEvent); + const ambEventData = nostrEventFixtures.ambMinimal({ + id: 'unique-event', + tags: [ + ['d', 'https://example.edu/unique-resource.png'], + ['type', 'Image'], + ], + }); + await saveEvent(ambEventData); - const oer = await oerExtractionService.extractOerFromEvent(ambEvent); + const oer = await oerExtractionService.extractOerFromEvent(ambEventData); expect(oer).toBeDefined(); expect(oer.url).toBe('https://example.edu/unique-resource.png'); - expect(oer.event_amb_id).toBe('unique-event'); const count = await oerRepository.count({ where: { url: 'https://example.edu/unique-resource.png' }, @@ -261,49 +277,45 @@ describe('OER Extraction Integration Tests (e2e)', () => { it('should update existing OER when new event is newer', async () => { // Create older event first - const olderEvent = nostrEventRepository.create( - nostrEventFixtures.ambMinimal({ - id: 'older-event', - created_at: 1000000000, - tags: [ - ['d', 'https://example.edu/same-url.png'], - ['license:id', 'https://old-license.org'], - ['type', 'OldType'], - ['dateCreated', '2024-01-10T10:00:00Z'], - ], - }), - ); - await nostrEventRepository.save(olderEvent); + const olderEventData = nostrEventFixtures.ambMinimal({ + id: 'older-event', + created_at: 1000000000, + tags: [ + ['d', 'https://example.edu/same-url.png'], + ['license:id', 'https://old-license.org'], + ['type', 'OldType'], + ['dateCreated', '2024-01-10T10:00:00Z'], + ], + }); + await saveEvent(olderEventData); - const oer1 = await oerExtractionService.extractOerFromEvent(olderEvent); + const oer1 = + await oerExtractionService.extractOerFromEvent(olderEventData); expect(oer1.url).toBe('https://example.edu/same-url.png'); expect(oer1.license_uri).toBe('https://old-license.org'); - expect(oer1.event_amb_id).toBe('older-event'); const oer1Id = oer1.id; // Create newer event with same URL - const newerEvent = nostrEventRepository.create( - nostrEventFixtures.ambMinimal({ - id: 'newer-event', - created_at: 2000000000, - tags: [ - ['d', 'https://example.edu/same-url.png'], - ['license:id', 'https://new-license.org'], - ['type', 'NewType'], - ['dateCreated', '2024-02-20T10:00:00Z'], - ], - }), - ); - await nostrEventRepository.save(newerEvent); + const newerEventData = nostrEventFixtures.ambMinimal({ + id: 'newer-event', + created_at: 2000000000, + tags: [ + ['d', 'https://example.edu/same-url.png'], + ['license:id', 'https://new-license.org'], + ['type', 'NewType'], + ['dateCreated', '2024-02-20T10:00:00Z'], + ], + }); + await saveEvent(newerEventData); - const oer2 = await oerExtractionService.extractOerFromEvent(newerEvent); + const oer2 = + await oerExtractionService.extractOerFromEvent(newerEventData); // Should be the same OER record (updated) expect(oer2.id).toBe(oer1Id); expect(oer2.url).toBe('https://example.edu/same-url.png'); expect(oer2.license_uri).toBe('https://new-license.org'); - expect(oer2.event_amb_id).toBe('newer-event'); // Verify only one record exists const count = await oerRepository.count({ @@ -314,47 +326,43 @@ describe('OER Extraction Integration Tests (e2e)', () => { it('should not update existing OER when new event is older', async () => { // Create newer event first - const newerEvent = nostrEventRepository.create( - nostrEventFixtures.ambMinimal({ - id: 'newer-event-first', - created_at: 2000000000, - tags: [ - ['d', 'https://example.edu/another-url.png'], - ['license:id', 'https://newer-license.org'], - ['type', 'NewerType'], - ], - }), - ); - await nostrEventRepository.save(newerEvent); + const newerEventData = nostrEventFixtures.ambMinimal({ + id: 'newer-event-first', + created_at: 2000000000, + tags: [ + ['d', 'https://example.edu/another-url.png'], + ['license:id', 'https://newer-license.org'], + ['type', 'NewerType'], + ], + }); + await saveEvent(newerEventData); - const oer1 = await oerExtractionService.extractOerFromEvent(newerEvent); + const oer1 = + await oerExtractionService.extractOerFromEvent(newerEventData); expect(oer1.url).toBe('https://example.edu/another-url.png'); expect(oer1.license_uri).toBe('https://newer-license.org'); - expect(oer1.event_amb_id).toBe('newer-event-first'); const oer1Id = oer1.id; // Try to insert older event with same URL - const olderEvent = nostrEventRepository.create( - nostrEventFixtures.ambMinimal({ - id: 'older-event-after', - created_at: 1000000000, - tags: [ - ['d', 'https://example.edu/another-url.png'], - ['license:id', 'https://older-license.org'], - ['type', 'OlderType'], - ], - }), - ); - await nostrEventRepository.save(olderEvent); + const olderEventData = nostrEventFixtures.ambMinimal({ + id: 'older-event-after', + created_at: 1000000000, + tags: [ + ['d', 'https://example.edu/another-url.png'], + ['license:id', 'https://older-license.org'], + ['type', 'OlderType'], + ], + }); + await saveEvent(olderEventData); - const oer2 = await oerExtractionService.extractOerFromEvent(olderEvent); + const oer2 = + await oerExtractionService.extractOerFromEvent(olderEventData); // Should return the same OER without updating expect(oer2.id).toBe(oer1Id); expect(oer2.url).toBe('https://example.edu/another-url.png'); expect(oer2.license_uri).toBe('https://newer-license.org'); // Still the newer value - expect(oer2.event_amb_id).toBe('newer-event-first'); // Still references newer event // Verify only one record exists const count = await oerRepository.count({ @@ -367,44 +375,41 @@ describe('OER Extraction Integration Tests (e2e)', () => { const sameTimestamp = 1500000000; // Create first event - const firstEvent = nostrEventRepository.create( - nostrEventFixtures.ambMinimal({ - id: 'first-event-same-time', - created_at: sameTimestamp, - tags: [ - ['d', 'https://example.edu/same-time.png'], - ['license:id', 'https://first-license.org'], - ['type', 'FirstType'], - ], - }), - ); - await nostrEventRepository.save(firstEvent); + const firstEventData = nostrEventFixtures.ambMinimal({ + id: 'first-event-same-time', + created_at: sameTimestamp, + tags: [ + ['d', 'https://example.edu/same-time.png'], + ['license:id', 'https://first-license.org'], + ['type', 'FirstType'], + ], + }); + await saveEvent(firstEventData); - const oer1 = await oerExtractionService.extractOerFromEvent(firstEvent); + const oer1 = + await oerExtractionService.extractOerFromEvent(firstEventData); expect(oer1.license_uri).toBe('https://first-license.org'); const oer1Id = oer1.id; // Create second event with same timestamp - const secondEvent = nostrEventRepository.create( - nostrEventFixtures.ambMinimal({ - id: 'second-event-same-time', - created_at: sameTimestamp, - tags: [ - ['d', 'https://example.edu/same-time.png'], - ['license:id', 'https://second-license.org'], - ['type', 'SecondType'], - ], - }), - ); - await nostrEventRepository.save(secondEvent); + const secondEventData = nostrEventFixtures.ambMinimal({ + id: 'second-event-same-time', + created_at: sameTimestamp, + tags: [ + ['d', 'https://example.edu/same-time.png'], + ['license:id', 'https://second-license.org'], + ['type', 'SecondType'], + ], + }); + await saveEvent(secondEventData); - const oer2 = await oerExtractionService.extractOerFromEvent(secondEvent); + const oer2 = + await oerExtractionService.extractOerFromEvent(secondEventData); // Should not update (keeps first event's data) expect(oer2.id).toBe(oer1Id); expect(oer2.license_uri).toBe('https://first-license.org'); - expect(oer2.event_amb_id).toBe('first-event-same-time'); // Verify only one record exists const count = await oerRepository.count({ @@ -416,12 +421,12 @@ describe('OER Extraction Integration Tests (e2e)', () => { describe('URI field extraction', () => { it('should extract educational_level_uri and audience_uri from AMB metadata', async () => { - const ambEvent = nostrEventRepository.create( - nostrEventFixtures.ambWithUris({ id: 'amb-event-with-uris' }), - ); - await nostrEventRepository.save(ambEvent); + const ambEventData = nostrEventFixtures.ambWithUris({ + id: 'amb-event-with-uris', + }); + await saveEvent(ambEventData); - const oer = await oerExtractionService.extractOerFromEvent(ambEvent); + const oer = await oerExtractionService.extractOerFromEvent(ambEventData); expect(oer).toBeDefined(); expect(oer.educational_level_uri).toBe( @@ -448,12 +453,12 @@ describe('OER Extraction Integration Tests (e2e)', () => { }); it('should set URI fields to null when not present in metadata', async () => { - const ambEvent = nostrEventRepository.create( - nostrEventFixtures.ambMinimal({ id: 'amb-event-no-uris' }), - ); - await nostrEventRepository.save(ambEvent); + const ambEventData = nostrEventFixtures.ambMinimal({ + id: 'amb-event-no-uris', + }); + await saveEvent(ambEventData); - const oer = await oerExtractionService.extractOerFromEvent(ambEvent); + const oer = await oerExtractionService.extractOerFromEvent(ambEventData); expect(oer).toBeDefined(); expect(oer.educational_level_uri).toBeNull(); @@ -461,22 +466,20 @@ describe('OER Extraction Integration Tests (e2e)', () => { }); it('should handle partial URI fields (only educational_level_uri)', async () => { - const ambEvent = nostrEventRepository.create( - nostrEventFixtures.ambMinimal({ - id: 'amb-event-partial-uri-1', - tags: [ - ['d', 'https://example.edu/resource-partial-1.pdf'], - [ - 'educationalLevel:id', - 'http://purl.org/dcx/lrmi-vocabs/educationalLevel/highSchool', - ], - ['type', 'LearningResource'], + const ambEventData = nostrEventFixtures.ambMinimal({ + id: 'amb-event-partial-uri-1', + tags: [ + ['d', 'https://example.edu/resource-partial-1.pdf'], + [ + 'educationalLevel:id', + 'http://purl.org/dcx/lrmi-vocabs/educationalLevel/highSchool', ], - }), - ); - await nostrEventRepository.save(ambEvent); + ['type', 'LearningResource'], + ], + }); + await saveEvent(ambEventData); - const oer = await oerExtractionService.extractOerFromEvent(ambEvent); + const oer = await oerExtractionService.extractOerFromEvent(ambEventData); expect(oer).toBeDefined(); expect(oer.educational_level_uri).toBe( @@ -486,22 +489,20 @@ describe('OER Extraction Integration Tests (e2e)', () => { }); it('should handle partial URI fields (only audience_uri)', async () => { - const ambEvent = nostrEventRepository.create( - nostrEventFixtures.ambMinimal({ - id: 'amb-event-partial-uri-2', - tags: [ - ['d', 'https://example.edu/resource-partial-2.pdf'], - [ - 'audience:id', - 'http://purl.org/dcx/lrmi-vocabs/educationalAudienceRole/teacher', - ], - ['type', 'LearningResource'], + const ambEventData = nostrEventFixtures.ambMinimal({ + id: 'amb-event-partial-uri-2', + tags: [ + ['d', 'https://example.edu/resource-partial-2.pdf'], + [ + 'audience:id', + 'http://purl.org/dcx/lrmi-vocabs/educationalAudienceRole/teacher', ], - }), - ); - await nostrEventRepository.save(ambEvent); + ['type', 'LearningResource'], + ], + }); + await saveEvent(ambEventData); - const oer = await oerExtractionService.extractOerFromEvent(ambEvent); + const oer = await oerExtractionService.extractOerFromEvent(ambEventData); expect(oer).toBeDefined(); expect(oer.educational_level_uri).toBeNull();