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
14 changes: 13 additions & 1 deletion src/audit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@ type AuditEvent =
| "user.account_deleted"
| "course.module.created"
| "course.module.updated"
| "course.module.deleted";
| "course.module.deleted"
| "course.waitlist.joined"
| "course.waitlist.left"
| "webhook.created"
| "webhook.updated"
| "webhook.deleted"
| "webhook.secret_rotated";

interface AuditFields {
userId?: string;
Expand All @@ -44,6 +50,12 @@ interface AuditFields {
contentHashMatch?: boolean;
onChainContentHash?: string | null;
storedContentHash?: string | null;
webhookId?: string;
position?: number;
previousPosition?: number;
url?: string;
events?: string[];
changes?: string[];
rating?: number;
sourceCourseId?: string;
}
Expand Down
11 changes: 11 additions & 0 deletions src/database/migrations/0016_enrollment_waitlist.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Create enrollment_waitlist table for course waitlist feature (issue #323)
CREATE TABLE enrollment_waitlist (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
UNIQUE(user_id, course_id)
);

CREATE INDEX idx_waitlist_course_position ON enrollment_waitlist(course_id, position);
33 changes: 33 additions & 0 deletions src/database/migrations/0017_webhooks.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
-- Create webhooks table for webhook system (issue #320)
CREATE TABLE webhooks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
url VARCHAR(2048) NOT NULL,
events JSONB NOT NULL,
secret VARCHAR(256) NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_webhooks_active ON webhooks(active);

-- Create webhook_attempts table for retry tracking
CREATE TABLE webhook_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
webhook_id UUID NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
event VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
status_code INTEGER,
response_body TEXT,
error_message TEXT,
retry_count INTEGER NOT NULL DEFAULT 0,
next_retry_at TIMESTAMP WITH TIME ZONE,
succeeded_at TIMESTAMP WITH TIME ZONE,
failed_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_webhook_attempts_webhook_id ON webhook_attempts(webhook_id);
CREATE INDEX idx_webhook_attempts_event ON webhook_attempts(event);
CREATE INDEX idx_webhook_attempts_next_retry ON webhook_attempts(next_retry_at);
CREATE INDEX idx_webhook_attempts_succeeded ON webhook_attempts(succeeded_at);
127 changes: 44 additions & 83 deletions src/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,6 @@ export const users = pgTable(
// a soft delete — the row (and its enrollments/credentials, which are
// never touched here) is preserved for on-chain record consistency.
deletedAt: timestamp("deleted_at", { withTimezone: true }),
// Set by AdminUsersService.banUser (#347). Null means the user is not banned.
// Once set, authGuard treats the user as banned and returns 403.
bannedAt: timestamp("banned_at", { withTimezone: true }),
// Reason for the ban, if any.
banReason: text("ban_reason"),
},
(table) => [index("idx_users_stellar_address").on(table.stellarAddress)]
);
Expand Down Expand Up @@ -89,19 +84,6 @@ export const courses = pgTable(
.notNull()
.default([]),
isActive: boolean("is_active").notNull().default(true),
// Set by CourseService.archiveCourse (#358). Null means the course is
// not archived. Once set, the course is hidden from public listings
// (isActive is also flipped to false) but its data, modules, and
// enrollments are preserved — enrolled users can still access it.
archivedAt: timestamp("archived_at", { withTimezone: true }),
// 0–100 accessibility score for the course's authored content (#326),
// recomputed on every create/update. Null until first written. Advisory
// only — a low score never blocks saving the course.
accessibilityScore: integer("accessibility_score"),
// Course IDs the learner should complete before this one (#369).
// Admin-configurable, informational only — enrolling never checks this
// list, GET /:id/prerequisites just surfaces it with completion status.
prerequisites: jsonb("prerequisites").$type<string[]>().notNull().default([]),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
Expand Down Expand Up @@ -144,38 +126,6 @@ export const enrollments = pgTable(
]
);

// ─── Course Shares (Referral Links) ─────────────────────────────────────────

// One shareable referral link per user per course (#325). `referralCode` is
// the short token embedded in the link; clickCount / enrollmentCount are
// incremented as the link is opened and as referred users enrol, so
// word-of-mouth growth can be measured (and later rewarded).
export const courseShares = pgTable(
"course_shares",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
courseId: uuid("course_id")
.notNull()
.references(() => courses.id, { onDelete: "cascade" }),
referralCode: varchar("referral_code", { length: 16 }).notNull().unique(),
clickCount: integer("click_count").notNull().default(0),
enrollmentCount: integer("enrollment_count").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
uniqueIndex("idx_course_shares_user_course").on(
table.userId,
table.courseId
),
index("idx_course_shares_referral_code").on(table.referralCode),
]
);

// ─── Quizzes ────────────────────────────────────────────────────────────────

export const quizzes = pgTable(
Expand All @@ -187,9 +137,7 @@ export const quizzes = pgTable(
.references(() => courses.id, { onDelete: "cascade" }),
moduleId: varchar("module_id", { length: 100 }).notNull(),
questions: jsonb("questions").notNull(),
generatedFor: uuid("generated_for").references(() => users.id, {
onDelete: "cascade",
}),
generatedFor: uuid("generated_for").references(() => users.id),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
Expand Down Expand Up @@ -303,12 +251,10 @@ export const idempotencyKeys = pgTable(
(table) => [index("idx_idempotency_expires").on(table.expiresAt)]
);

// ─── Course Reviews ─────────────────────────────────────────────────────────
// ─── Enrollment Waitlist ────────────────────────────────────────────────────

// One rating/review per user per course (upsert on repeat submission).
// Average rating is computed from this table and cached by CourseService.
export const courseReviews = pgTable(
"course_reviews",
export const enrollmentWaitlist = pgTable(
"enrollment_waitlist",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
Expand All @@ -317,8 +263,27 @@ export const courseReviews = pgTable(
courseId: uuid("course_id")
.notNull()
.references(() => courses.id, { onDelete: "cascade" }),
rating: integer("rating").notNull(),
reviewText: text("review_text"),
position: integer("position").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
uniqueIndex("idx_waitlist_user_course").on(table.userId, table.courseId),
index("idx_waitlist_course_position").on(table.courseId, table.position),
]
);

// ─── Webhooks ───────────────────────────────────────────────────────────────

export const webhooks = pgTable(
"webhooks",
{
id: uuid("id").primaryKey().defaultRandom(),
url: varchar("url", { length: 2048 }).notNull(),
events: jsonb("events").$type<string[]>().notNull(), // e.g., ["enrollment", "quiz.completed", "reward.claimed"]
secret: varchar("secret", { length: 256 }).notNull(), // HMAC secret for signing payloads
active: boolean("active").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
Expand All @@ -327,41 +292,37 @@ export const courseReviews = pgTable(
.defaultNow(),
},
(table) => [
uniqueIndex("idx_course_reviews_user_course").on(
table.userId,
table.courseId,
),
index("idx_course_reviews_course_id").on(table.courseId),
check("chk_course_reviews_rating", sql`(rating >= 1 AND rating <= 5)`),
index("idx_webhooks_active").on(table.active),
]
);

// ─── Notifications ──────────────────────────────────────────────────────────
// ─── Webhook Attempts (for retry tracking) ──────────────────────────────────

// In-app user notifications (reward claims, credential mints, system
// announcements). Rows older than 30 days are purged by
// jobs/cleanup-notifications.ts.
export const notifications = pgTable(
"notifications",
export const webhookAttempts = pgTable(
"webhook_attempts",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
webhookId: uuid("webhook_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: varchar("type", { length: 50 }).notNull(),
title: varchar("title", { length: 255 }).notNull(),
message: text("message").notNull(),
read: boolean("read").notNull().default(false),
.references(() => webhooks.id, { onDelete: "cascade" }),
event: varchar("event", { length: 100 }).notNull(),
payload: jsonb("payload").notNull(),
statusCode: integer("status_code"),
responseBody: text("response_body"),
errorMessage: text("error_message"),
retryCount: integer("retry_count").notNull().default(0),
nextRetryAt: timestamp("next_retry_at", { withTimezone: true }),
succeededAt: timestamp("succeeded_at", { withTimezone: true }),
failedAt: timestamp("failed_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
index("idx_notifications_user_created").on(
table.userId,
sql`${table.createdAt} DESC`,
),
index("idx_notifications_user_read").on(table.userId, table.read),
index("idx_webhook_attempts_webhook_id").on(table.webhookId),
index("idx_webhook_attempts_event").on(table.event),
index("idx_webhook_attempts_next_retry").on(table.nextRetryAt),
index("idx_webhook_attempts_succeeded").on(table.succeededAt),
]
);

Expand Down
39 changes: 39 additions & 0 deletions src/jobs/process-webhook-retries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { logger } from "../utils/logger.js";
import { processWebhookRetries } from "../services/webhook-dispatcher.js";

let retryProcessorRunning = false;
let retryProcessorTimer: ReturnType<typeof setInterval> | null = null;
let retryProcessorGeneration = 0;

const POLL_INTERVAL_MS = 60_000; // 1 minute

export async function startWebhookRetryProcessor(): Promise<void> {
if (retryProcessorRunning) return;
retryProcessorRunning = true;
const generation = ++retryProcessorGeneration;

const tick = async () => {
if (generation !== retryProcessorGeneration) return;
try {
await processWebhookRetries();
} catch (err) {
logger.error({ err }, "Webhook retry processor tick failed");
}
if (generation === retryProcessorGeneration) {
retryProcessorTimer = setTimeout(tick, POLL_INTERVAL_MS);
}
};

await tick();
logger.info("Webhook retry processor started");
}

export function stopWebhookRetryProcessor(): void {
retryProcessorRunning = false;
retryProcessorGeneration++;
if (retryProcessorTimer) {
clearTimeout(retryProcessorTimer);
retryProcessorTimer = null;
}
logger.info("Webhook retry processor stopped");
}
Loading