Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 17 additions & 14 deletions apps/access-api/src/handlers/contributionScoreHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OutboxEventType> = 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 {
Expand All @@ -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<PrismaClient> {
async function resolvePrisma(): Promise<PrismaClient> {
if (config.db) return config.db;
if (!prismaSingleton) {
const { getPrisma } = require('../services/prisma');
prismaSingleton = getPrisma();
prismaSingleton = getPrismaSingleton();
}
return prismaSingleton!;
}
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions apps/access-api/src/services/attendance/attendanceService.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 7 additions & 5 deletions apps/access-api/src/services/contractEventHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -544,7 +547,6 @@ export async function applyContractEvent(
},
});
} else if (event.type === 'OwnershipTransferred') {
const previousOwner = event.previousOwner.toLowerCase();
const newOwner = event.newOwner.toLowerCase();


Expand Down
13 changes: 8 additions & 5 deletions apps/access-api/src/services/memberService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
RoleDefinition,
DelegatedGrant,
PaginatedResponse,
OUTBOX_EVENT_TYPES,
} from "@guildpass/shared-types";
import {
createDefaultEngine,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
54 changes: 54 additions & 0 deletions apps/access-api/src/services/outboxEventTypes.test.ts
Original file line number Diff line number Diff line change
@@ -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_]*["']/,
);
});
});
4 changes: 1 addition & 3 deletions apps/access-api/src/services/outboxService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
9 changes: 5 additions & 4 deletions apps/access-api/src/services/resourceService.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 9 additions & 5 deletions apps/access-api/src/services/rewardEngineService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OutboxEventType> = 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 {
Expand Down
3 changes: 2 additions & 1 deletion apps/access-api/src/workers/outboxWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
Loading