diff --git a/apps/access-api/src/handlers/contributionScoreHandler.ts b/apps/access-api/src/handlers/contributionScoreHandler.ts index 5619d0b..0879bf5 100644 --- a/apps/access-api/src/handlers/contributionScoreHandler.ts +++ b/apps/access-api/src/handlers/contributionScoreHandler.ts @@ -20,21 +20,26 @@ */ import type { PrismaClient } from '@prisma/client'; +import { + OUTBOX_EVENT_TYPES, + type OutboxEventType, +} from '@guildpass/shared-types'; import { recomputeAndPersist } from '../services/contributionService'; +import { getPrisma as getPrismaSingleton } from '../services/prisma'; import type { OutboxEventHandler } from '../workers/outboxWorker'; /** * Event types that should trigger a contribution score recomputation. */ -const SCORE_RECOMPUTE_EVENTS = new Set([ - 'ROLE_ASSIGNED', - 'ROLE_REMOVED', - 'BADGE_ASSIGNED', - 'BADGE_REVOKED', - 'MEMBER_ATTENDED', - 'EVENT_ATTENDANCE_RECORDED', - 'MEMBERSHIP_CREATED', - 'MEMBERSHIP_UPDATED', +const SCORE_RECOMPUTE_EVENTS: ReadonlySet = new Set([ + OUTBOX_EVENT_TYPES.ROLE_ASSIGNED, + OUTBOX_EVENT_TYPES.ROLE_REMOVED, + OUTBOX_EVENT_TYPES.BADGE_ASSIGNED, + OUTBOX_EVENT_TYPES.BADGE_REVOKED, + OUTBOX_EVENT_TYPES.MEMBER_ATTENDED, + OUTBOX_EVENT_TYPES.EVENT_ATTENDANCE_RECORDED, + OUTBOX_EVENT_TYPES.MEMBERSHIP_CREATED, + OUTBOX_EVENT_TYPES.MEMBERSHIP_UPDATED, ]); export interface ContributionScoreHandlerConfig { @@ -55,14 +60,12 @@ export interface ContributionScoreHandlerConfig { export function createContributionScoreHandler( config: ContributionScoreHandlerConfig = {}, ): OutboxEventHandler { - // Lazy import to avoid circular dependency at module load time let prismaSingleton: PrismaClient | null = null; - async function getPrisma(): Promise { + async function resolvePrisma(): Promise { if (config.db) return config.db; if (!prismaSingleton) { - const { getPrisma } = require('../services/prisma'); - prismaSingleton = getPrisma(); + prismaSingleton = getPrismaSingleton(); } return prismaSingleton!; } @@ -82,7 +85,7 @@ export function createContributionScoreHandler( } try { - const db = await getPrisma(); + const db = await resolvePrisma(); await recomputeAndPersist(db, wallet, communityId); } catch (err: any) { // Log but don't throw — the score will be recomputed on the next diff --git a/apps/access-api/src/services/attendance/attendanceService.ts b/apps/access-api/src/services/attendance/attendanceService.ts index 87f9fee..b37bd37 100644 --- a/apps/access-api/src/services/attendance/attendanceService.ts +++ b/apps/access-api/src/services/attendance/attendanceService.ts @@ -1,5 +1,6 @@ import { createHmac, timingSafeEqual } from 'node:crypto'; import type { PrismaClient, AttendanceRecord } from '@prisma/client'; +import { OUTBOX_EVENT_TYPES } from '@guildpass/shared-types'; export interface IngestedAttendance { walletAddress: string; @@ -114,10 +115,10 @@ export function getAttendanceService(prisma: PrismaClient) { }, }); - // Emit "MEMBER_ATTENDED" outbox event + // Emit member-attended outbox event. await tx.outboxEvent.create({ data: { - eventType: 'MEMBER_ATTENDED', + eventType: OUTBOX_EVENT_TYPES.MEMBER_ATTENDED, entityId: record.id, entityType: 'AttendanceRecord', communityId: record.communityId, diff --git a/apps/access-api/src/services/contractEventHelpers.ts b/apps/access-api/src/services/contractEventHelpers.ts index 4d64190..caf11cf 100644 --- a/apps/access-api/src/services/contractEventHelpers.ts +++ b/apps/access-api/src/services/contractEventHelpers.ts @@ -11,7 +11,8 @@ * for the MembershipNFT contract ABI and typed event definitions. */ -import type { PrismaClient, Prisma } from '@prisma/client'; +import type { PrismaClient } from '@prisma/client'; +import { OUTBOX_EVENT_TYPES } from '@guildpass/shared-types'; import { writeChainedAuditEvent } from './auditChainHasher'; import type { @@ -257,7 +258,7 @@ export async function applyContractEvent( // Create outbox event with on-chain metadata for downstream consumers await tx.outboxEvent.create({ data: { - eventType: 'MEMBERSHIP_CREATED', + eventType: OUTBOX_EVENT_TYPES.MEMBERSHIP_CREATED, entityId: updatedMembership.id, entityType: 'Membership', communityId: event.communityId, @@ -346,7 +347,7 @@ export async function applyContractEvent( // Create outbox event with on-chain metadata await tx.outboxEvent.create({ data: { - eventType: 'MEMBERSHIP_RENEWED', + eventType: OUTBOX_EVENT_TYPES.MEMBERSHIP_RENEWED, entityId: token.member.membership?.id ?? 'unknown', entityType: 'Membership', communityId: token.member.communityId, @@ -431,7 +432,9 @@ export async function applyContractEvent( // Create outbox event with on-chain metadata await tx.outboxEvent.create({ data: { - eventType: event.isSuspended ? 'MEMBERSHIP_SUSPENDED' : 'MEMBERSHIP_UNSUSPENDED', + eventType: event.isSuspended + ? OUTBOX_EVENT_TYPES.MEMBERSHIP_SUSPENDED + : OUTBOX_EVENT_TYPES.MEMBERSHIP_UNSUSPENDED, entityId: token.member.membership?.id ?? 'unknown', entityType: 'Membership', communityId: token.member.communityId, @@ -544,7 +547,6 @@ export async function applyContractEvent( }, }); } else if (event.type === 'OwnershipTransferred') { - const previousOwner = event.previousOwner.toLowerCase(); const newOwner = event.newOwner.toLowerCase(); diff --git a/apps/access-api/src/services/memberService.ts b/apps/access-api/src/services/memberService.ts index e41dd9d..37d8987 100644 --- a/apps/access-api/src/services/memberService.ts +++ b/apps/access-api/src/services/memberService.ts @@ -20,6 +20,7 @@ import { RoleDefinition, DelegatedGrant, PaginatedResponse, + OUTBOX_EVENT_TYPES, } from "@guildpass/shared-types"; import { createDefaultEngine, @@ -1161,7 +1162,9 @@ export function getMemberService( } await logOutboxEventTx(tx, { - eventType: existing ? "ACCESS_OVERRIDE_UPDATED" : "ACCESS_OVERRIDE_CREATED", + eventType: existing + ? OUTBOX_EVENT_TYPES.ACCESS_OVERRIDE_UPDATED + : OUTBOX_EVENT_TYPES.ACCESS_OVERRIDE_CREATED, entityId: record.id, entityType: "AccessOverride", communityId, @@ -1235,7 +1238,7 @@ export function getMemberService( await tx.accessOverride.delete({ where: { id: existing.id } }); await logOutboxEventTx(tx, { - eventType: "ACCESS_OVERRIDE_REVOKED", + eventType: OUTBOX_EVENT_TYPES.ACCESS_OVERRIDE_REVOKED, entityId: existing.id, entityType: "AccessOverride", communityId, @@ -1389,7 +1392,7 @@ export function getMemberService( }); await logOutboxEventTx(tx, { - eventType: "BADGE_ASSIGNED", + eventType: OUTBOX_EVENT_TYPES.BADGE_ASSIGNED, entityId: created.id, entityType: "Badge", communityId, @@ -1457,7 +1460,7 @@ export function getMemberService( await prismaClient.$transaction(async (tx: any) => { await tx.badge.delete({ where: { id: existing.id } }); await logOutboxEventTx(tx, { - eventType: "BADGE_REVOKED", + eventType: OUTBOX_EVENT_TYPES.BADGE_REVOKED, entityId: existing.id, entityType: "Badge", communityId, @@ -1547,7 +1550,7 @@ export function getMemberService( await bumpPolicyVersion(communityId); await logOutboxEventTx(prismaClient, { - eventType: "POLICY_UPDATED", + eventType: OUTBOX_EVENT_TYPES.POLICY_UPDATED, entityId: policy.id, entityType: "AccessPolicy", communityId, diff --git a/apps/access-api/src/services/outboxEventTypes.test.ts b/apps/access-api/src/services/outboxEventTypes.test.ts new file mode 100644 index 0000000..555e618 --- /dev/null +++ b/apps/access-api/src/services/outboxEventTypes.test.ts @@ -0,0 +1,54 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { + OUTBOX_EVENT_TYPES, + OUTBOX_EVENT_TYPE_VALUES, +} from "@guildpass/shared-types"; + +describe("shared outbox event types", () => { + it("exports unique event type values for outbox producers and consumers", () => { + expect(OUTBOX_EVENT_TYPE_VALUES).toContain(OUTBOX_EVENT_TYPES.MEMBERSHIP_CREATED); + expect(OUTBOX_EVENT_TYPE_VALUES).toContain(OUTBOX_EVENT_TYPES.ROLE_ASSIGNED); + expect(OUTBOX_EVENT_TYPE_VALUES).toContain(OUTBOX_EVENT_TYPES.ACCESS_DECISION); + expect(new Set(OUTBOX_EVENT_TYPE_VALUES).size).toBe(OUTBOX_EVENT_TYPE_VALUES.length); + }); + + it("keeps production outbox modules on the shared constants", () => { + const repoRoot = path.resolve(__dirname, "../../../.."); + const checkedFiles = [ + "apps/access-api/src/services/outboxService.ts", + "apps/access-api/src/services/memberService.ts", + "apps/access-api/src/services/resourceService.ts", + "apps/access-api/src/services/attendance/attendanceService.ts", + "apps/access-api/src/services/rewardEngineService.ts", + "apps/access-api/src/handlers/contributionScoreHandler.ts", + "apps/access-api/src/workers/outboxWorker.ts", + ]; + + const rawOutboxEventLiteral = + /eventType\s*:\s*["'](MEMBERSHIP|ROLE|RESOURCE|POLICY|ACCESS_OVERRIDE|MEMBER_ATTENDED|EVENT_|BADGE|CONTRIBUTION|CONSTITUTIONAL|CONTRACT)[A-Z0-9_]*["']/; + const offenders = checkedFiles.filter((file) => { + const source = fs.readFileSync(path.join(repoRoot, file), "utf8"); + return rawOutboxEventLiteral.test(source); + }); + + expect(offenders).toEqual([]); + + const contributionHandler = fs.readFileSync( + path.join(repoRoot, "apps/access-api/src/handlers/contributionScoreHandler.ts"), + "utf8", + ); + const rewardService = fs.readFileSync( + path.join(repoRoot, "apps/access-api/src/services/rewardEngineService.ts"), + "utf8", + ); + + expect(contributionHandler).not.toMatch( + /SCORE_RECOMPUTE_EVENTS[\s\S]*?new Set\(\[[\s\S]*?["'][A-Z][A-Z0-9_]*["']/, + ); + expect(rewardService).not.toMatch( + /REWARD_EVENTS[\s\S]*?new Set\(\[[\s\S]*?["'][A-Z][A-Z0-9_]*["']/, + ); + }); +}); diff --git a/apps/access-api/src/services/outboxService.ts b/apps/access-api/src/services/outboxService.ts index 9c95b8d..b068d75 100644 --- a/apps/access-api/src/services/outboxService.ts +++ b/apps/access-api/src/services/outboxService.ts @@ -2,9 +2,7 @@ import { randomUUID } from "node:crypto"; import { Prisma, type PrismaClient } from "@prisma/client"; import type { OutboxEventType, - OutboxEventDto, OutboxDispatchResult, - OutboxEventStatus, } from "@guildpass/shared-types"; import { getCorrelationId } from "./requestContext"; import { metrics } from "../observability/metrics"; @@ -211,7 +209,7 @@ const DEFAULT_CLAIM_LEASE_MS = 60_000; export interface ClaimedOutboxEvent { id: string; - eventType: string; + eventType: OutboxEventType; entityId: string | null; entityType: string | null; communityId: string | null; diff --git a/apps/access-api/src/services/resourceService.ts b/apps/access-api/src/services/resourceService.ts index ca3d8bc..f048fe8 100644 --- a/apps/access-api/src/services/resourceService.ts +++ b/apps/access-api/src/services/resourceService.ts @@ -1,4 +1,5 @@ import type { PrismaClient } from '@prisma/client'; +import { OUTBOX_EVENT_TYPES } from '@guildpass/shared-types'; import { logOutboxEventTx } from './outboxService'; import { requirePermission } from '../lib/auth/permissions'; @@ -182,7 +183,7 @@ export function getResourceService(prisma: PrismaClient) { }); await logOutboxEventTx(tx, { - eventType: "RESOURCE_UPDATED", + eventType: OUTBOX_EVENT_TYPES.RESOURCE_UPDATED, entityId: normalizedResourceId, entityType: "Resource", communityId: normalizedCommunityId, @@ -217,7 +218,7 @@ export function getResourceService(prisma: PrismaClient) { }); await logOutboxEventTx(tx, { - eventType: "RESOURCE_CREATED", + eventType: OUTBOX_EVENT_TYPES.RESOURCE_CREATED, entityId: normalizedResourceId, entityType: "Resource", communityId: normalizedCommunityId, @@ -294,7 +295,7 @@ export function getResourceService(prisma: PrismaClient) { }); await logOutboxEventTx(tx, { - eventType: "RESOURCE_UPDATED", + eventType: OUTBOX_EVENT_TYPES.RESOURCE_UPDATED, entityId: normalizedResourceId, entityType: "Resource", communityId: normalizedCommunityId, @@ -360,7 +361,7 @@ export function getResourceService(prisma: PrismaClient) { }); await logOutboxEventTx(tx, { - eventType: "RESOURCE_ARCHIVED", + eventType: OUTBOX_EVENT_TYPES.RESOURCE_ARCHIVED, entityId: normalizedResourceId, entityType: "Resource", communityId: normalizedCommunityId, diff --git a/apps/access-api/src/services/rewardEngineService.ts b/apps/access-api/src/services/rewardEngineService.ts index 39ba923..214fee6 100644 --- a/apps/access-api/src/services/rewardEngineService.ts +++ b/apps/access-api/src/services/rewardEngineService.ts @@ -6,13 +6,17 @@ import { type RewardEvent, type StreakPeriod, } from "@guildpass/reward-engine"; +import { + OUTBOX_EVENT_TYPES, + type OutboxEventType, +} from "@guildpass/shared-types"; import type { OutboxEventHandler } from "../workers/outboxWorker"; -const REWARD_EVENTS = new Set([ - "MEMBERSHIP_CREATED", - "MEMBER_ATTENDED", - "EVENT_ATTENDANCE_RECORDED", - "CONTRIBUTION_SCORE_UPDATED", +const REWARD_EVENTS: ReadonlySet = new Set([ + OUTBOX_EVENT_TYPES.MEMBERSHIP_CREATED, + OUTBOX_EVENT_TYPES.MEMBER_ATTENDED, + OUTBOX_EVENT_TYPES.EVENT_ATTENDANCE_RECORDED, + OUTBOX_EVENT_TYPES.CONTRIBUTION_SCORE_UPDATED, ]); export interface RewardConsumerOptions { diff --git a/apps/access-api/src/workers/outboxWorker.ts b/apps/access-api/src/workers/outboxWorker.ts index 2d9dfc9..1aa32e8 100644 --- a/apps/access-api/src/workers/outboxWorker.ts +++ b/apps/access-api/src/workers/outboxWorker.ts @@ -38,6 +38,7 @@ import { randomUUID } from "node:crypto"; import { PrismaClient } from "@prisma/client"; +import type { OutboxEventType } from "@guildpass/shared-types"; import { getPrisma } from "../services/prisma"; import { claimPendingOutboxEvents, @@ -86,7 +87,7 @@ const BACKLOG_REPORT_INTERVAL_MS = 15_000; */ export type OutboxEventHandler = (event: { id: string; - eventType: string; + eventType: OutboxEventType; entityId: string | null; entityType: string | null; communityId: string | null; diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 12c6b09..5ff9d1b 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -402,36 +402,44 @@ export type EventType = // --- Integration Event Outbox --- +export const OUTBOX_EVENT_TYPES = { + MEMBERSHIP_CREATED: "MEMBERSHIP_CREATED", + MEMBERSHIP_UPDATED: "MEMBERSHIP_UPDATED", + MEMBERSHIP_RENEWED: "MEMBERSHIP_RENEWED", + MEMBERSHIP_SUSPENDED: "MEMBERSHIP_SUSPENDED", + MEMBERSHIP_UNSUSPENDED: "MEMBERSHIP_UNSUSPENDED", + MEMBERSHIP_REINSTATED: "MEMBERSHIP_REINSTATED", + MEMBERSHIP_UNSUSPEND_REQUESTED: "MEMBERSHIP_UNSUSPEND_REQUESTED", + MEMBERSHIP_DELETED: "MEMBERSHIP_DELETED", + ROLE_ASSIGNED: "ROLE_ASSIGNED", + ROLE_REMOVED: "ROLE_REMOVED", + RESOURCE_CREATED: "RESOURCE_CREATED", + RESOURCE_UPDATED: "RESOURCE_UPDATED", + RESOURCE_ARCHIVED: "RESOURCE_ARCHIVED", + POLICY_CREATED: "POLICY_CREATED", + POLICY_UPDATED: "POLICY_UPDATED", + POLICY_DELETED: "POLICY_DELETED", + ACCESS_DECISION: "ACCESS_DECISION", + ACCESS_OVERRIDE_CREATED: "ACCESS_OVERRIDE_CREATED", + ACCESS_OVERRIDE_UPDATED: "ACCESS_OVERRIDE_UPDATED", + ACCESS_OVERRIDE_REVOKED: "ACCESS_OVERRIDE_REVOKED", + MEMBER_ATTENDED: "MEMBER_ATTENDED", + EVENT_CREATED: "EVENT_CREATED", + EVENT_UPDATED: "EVENT_UPDATED", + EVENT_DELETED: "EVENT_DELETED", + EVENT_ATTENDANCE_RECORDED: "EVENT_ATTENDANCE_RECORDED", + BADGE_ASSIGNED: "BADGE_ASSIGNED", + BADGE_REVOKED: "BADGE_REVOKED", + CONTRIBUTION_SCORE_UPDATED: "CONTRIBUTION_SCORE_UPDATED", + CONSTITUTIONAL_RULESET_CREATED: "CONSTITUTIONAL_RULESET_CREATED", + CONTRACT_ADMIN_UPDATED: "CONTRACT_ADMIN_UPDATED", + CONTRACT_OWNERSHIP_TRANSFERRED: "CONTRACT_OWNERSHIP_TRANSFERRED", +} as const; + export type OutboxEventType = - | "MEMBERSHIP_CREATED" - | "MEMBERSHIP_UPDATED" - | "MEMBERSHIP_RENEWED" - | "MEMBERSHIP_SUSPENDED" - | "MEMBERSHIP_UNSUSPENDED" - | "MEMBERSHIP_REINSTATED" - | "MEMBERSHIP_UNSUSPEND_REQUESTED" - | "MEMBERSHIP_DELETED" - | "ROLE_ASSIGNED" - | "ROLE_REMOVED" - | "RESOURCE_CREATED" - | "RESOURCE_UPDATED" - | "RESOURCE_ARCHIVED" - | "POLICY_CREATED" - | "POLICY_UPDATED" - | "POLICY_DELETED" - | "ACCESS_DECISION" - | "ACCESS_OVERRIDE_CREATED" - | "ACCESS_OVERRIDE_UPDATED" - | "ACCESS_OVERRIDE_REVOKED" - | "MEMBER_ATTENDED" - | "EVENT_CREATED" - | "EVENT_UPDATED" - | "EVENT_DELETED" - | "EVENT_ATTENDANCE_RECORDED" - | "BADGE_ASSIGNED" - | "BADGE_REVOKED" - | "CONSTITUTIONAL_RULESET_CREATED" - | (string & {}); + (typeof OUTBOX_EVENT_TYPES)[keyof typeof OUTBOX_EVENT_TYPES]; + +export const OUTBOX_EVENT_TYPE_VALUES = Object.values(OUTBOX_EVENT_TYPES); export type OutboxEventStatus = "pending" | "delivered" | "failed";