From 9d4b685f1ca99b28de3872ef0ab91e1b63f8514c Mon Sep 17 00:00:00 2001 From: Guido X Jansen Date: Tue, 10 Mar 2026 13:28:28 +0100 Subject: [PATCH] feat(appview): add community rules system (#174) Three new tables: community_rules, community_rule_versions, moderation_action_rules. Rules are versioned -- editing a rule creates a new version row so historical warnings reference the original text. API endpoints: - GET /api/communities/:did/rules (public) - POST /api/communities/:did/rules (admin) - PUT /api/communities/:did/rules/:id (admin, creates new version) - DELETE /api/communities/:did/rules/:id (admin, soft-delete) - PUT /api/communities/:did/rules/reorder (admin) - GET /api/communities/:did/rules/:id/versions (admin) Includes 34 unit tests covering validation schemas and route handlers. Closes singi-labs/barazo-workspace#97 --- drizzle.config.ts | 3 + drizzle/0013_community-rules.sql | 41 ++ drizzle/meta/_journal.json | 9 +- src/app.ts | 2 + src/db/schema/community-rule-versions.ts | 24 + src/db/schema/community-rules.ts | 28 + src/db/schema/index.ts | 3 + src/db/schema/moderation-action-rules.ts | 31 + src/routes/community-rules.ts | 559 +++++++++++++++++ src/validation/community-rules.ts | 31 + tests/unit/routes/community-rules.test.ts | 590 ++++++++++++++++++ tests/unit/validation/community-rules.test.ts | 136 ++++ 12 files changed, 1456 insertions(+), 1 deletion(-) create mode 100644 drizzle/0013_community-rules.sql create mode 100644 src/db/schema/community-rule-versions.ts create mode 100644 src/db/schema/community-rules.ts create mode 100644 src/db/schema/moderation-action-rules.ts create mode 100644 src/routes/community-rules.ts create mode 100644 src/validation/community-rules.ts create mode 100644 tests/unit/routes/community-rules.test.ts create mode 100644 tests/unit/validation/community-rules.test.ts diff --git a/drizzle.config.ts b/drizzle.config.ts index a794118..c99a3f1 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -36,6 +36,9 @@ export default defineConfig({ './src/db/schema/mod-notes.ts', './src/db/schema/topic-notices.ts', './src/db/schema/mod-warnings.ts', + './src/db/schema/community-rules.ts', + './src/db/schema/community-rule-versions.ts', + './src/db/schema/moderation-action-rules.ts', ], out: './drizzle', dialect: 'postgresql', diff --git a/drizzle/0013_community-rules.sql b/drizzle/0013_community-rules.sql new file mode 100644 index 0000000..11bb1d7 --- /dev/null +++ b/drizzle/0013_community-rules.sql @@ -0,0 +1,41 @@ +CREATE TABLE "community_rules" ( + "id" serial PRIMARY KEY NOT NULL, + "community_did" text NOT NULL, + "title" text NOT NULL, + "description" text NOT NULL, + "display_order" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "archived_at" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "community_rules" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "community_rule_versions" ( + "id" serial PRIMARY KEY NOT NULL, + "rule_id" integer NOT NULL, + "title" text NOT NULL, + "description" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "community_rule_versions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "moderation_action_rules" ( + "id" serial PRIMARY KEY NOT NULL, + "warning_id" integer, + "moderation_action_id" integer, + "rule_version_id" integer NOT NULL, + "community_did" text NOT NULL, + CONSTRAINT "exactly_one_parent" CHECK ((warning_id IS NOT NULL AND moderation_action_id IS NULL) OR (warning_id IS NULL AND moderation_action_id IS NOT NULL)) +); +--> statement-breakpoint +ALTER TABLE "moderation_action_rules" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE INDEX "community_rules_community_did_idx" ON "community_rules" USING btree ("community_did");--> statement-breakpoint +CREATE INDEX "community_rules_display_order_idx" ON "community_rules" USING btree ("display_order");--> statement-breakpoint +CREATE INDEX "community_rule_versions_rule_id_idx" ON "community_rule_versions" USING btree ("rule_id");--> statement-breakpoint +CREATE INDEX "mod_action_rules_warning_id_idx" ON "moderation_action_rules" USING btree ("warning_id");--> statement-breakpoint +CREATE INDEX "mod_action_rules_moderation_action_id_idx" ON "moderation_action_rules" USING btree ("moderation_action_id");--> statement-breakpoint +CREATE INDEX "mod_action_rules_rule_version_id_idx" ON "moderation_action_rules" USING btree ("rule_version_id");--> statement-breakpoint +CREATE INDEX "mod_action_rules_community_did_idx" ON "moderation_action_rules" USING btree ("community_did");--> statement-breakpoint +CREATE POLICY "tenant_isolation" ON "community_rules" AS PERMISSIVE FOR ALL TO "barazo_app" USING (community_did = current_setting('app.current_community_did', true)) WITH CHECK (community_did = current_setting('app.current_community_did', true));--> statement-breakpoint +CREATE POLICY "tenant_isolation" ON "community_rule_versions" AS PERMISSIVE FOR ALL TO "barazo_app" USING (rule_id IN (SELECT id FROM community_rules WHERE community_did = current_setting('app.current_community_did', true))) WITH CHECK (rule_id IN (SELECT id FROM community_rules WHERE community_did = current_setting('app.current_community_did', true)));--> statement-breakpoint +CREATE POLICY "tenant_isolation" ON "moderation_action_rules" AS PERMISSIVE FOR ALL TO "barazo_app" USING (community_did = current_setting('app.current_community_did', true)) WITH CHECK (community_did = current_setting('app.current_community_did', true)); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d35c2cb..24a1635 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1773062314074, "tag": "0012_overconfident_jackal", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1773331200000, + "tag": "0013_community-rules", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/app.ts b/src/app.ts index 6d73813..51f578e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -48,6 +48,7 @@ import { uploadRoutes } from './routes/uploads.js' import { adminSybilRoutes } from './routes/admin-sybil.js' import { adminDesignRoutes } from './routes/admin-design.js' import { adminPluginRoutes } from './routes/admin-plugins.js' +import { communityRulesRoutes } from './routes/community-rules.js' import { discoverPlugins, syncPluginsToDb, validateAndFilterPlugins } from './lib/plugins/loader.js' import { buildLoadedPlugin, executeHook, getPluginShortName } from './lib/plugins/runtime.js' import { createPluginContext, type CacheAdapter } from './lib/plugins/context.js' @@ -506,6 +507,7 @@ export async function buildApp(env: Env) { await app.register(adminSybilRoutes()) await app.register(adminDesignRoutes()) await app.register(adminPluginRoutes()) + await app.register(communityRulesRoutes()) // OpenAPI spec endpoint (after routes so all schemas are registered) app.get('/api/openapi.json', { schema: { hide: true } }, async (_request, reply) => { diff --git a/src/db/schema/community-rule-versions.ts b/src/db/schema/community-rule-versions.ts new file mode 100644 index 0000000..8903631 --- /dev/null +++ b/src/db/schema/community-rule-versions.ts @@ -0,0 +1,24 @@ +import { pgTable, pgPolicy, text, timestamp, index, serial, integer } from 'drizzle-orm/pg-core' +import { sql } from 'drizzle-orm' +import { appRole } from './roles.js' + +export const communityRuleVersions = pgTable( + 'community_rule_versions', + { + id: serial('id').primaryKey(), + ruleId: integer('rule_id').notNull(), + title: text('title').notNull(), + description: text('description').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index('community_rule_versions_rule_id_idx').on(table.ruleId), + pgPolicy('tenant_isolation', { + as: 'permissive', + to: appRole, + for: 'all', + using: sql`rule_id IN (SELECT id FROM community_rules WHERE community_did = current_setting('app.current_community_did', true))`, + withCheck: sql`rule_id IN (SELECT id FROM community_rules WHERE community_did = current_setting('app.current_community_did', true))`, + }), + ] +).enableRLS() diff --git a/src/db/schema/community-rules.ts b/src/db/schema/community-rules.ts new file mode 100644 index 0000000..96e7e7c --- /dev/null +++ b/src/db/schema/community-rules.ts @@ -0,0 +1,28 @@ +import { pgTable, pgPolicy, text, timestamp, index, serial, integer } from 'drizzle-orm/pg-core' +import { sql } from 'drizzle-orm' +import { appRole } from './roles.js' + +export const communityRules = pgTable( + 'community_rules', + { + id: serial('id').primaryKey(), + communityDid: text('community_did').notNull(), + title: text('title').notNull(), + description: text('description').notNull(), + displayOrder: integer('display_order').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + archivedAt: timestamp('archived_at', { withTimezone: true }), + }, + (table) => [ + index('community_rules_community_did_idx').on(table.communityDid), + index('community_rules_display_order_idx').on(table.displayOrder), + pgPolicy('tenant_isolation', { + as: 'permissive', + to: appRole, + for: 'all', + using: sql`community_did = current_setting('app.current_community_did', true)`, + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, + }), + ] +).enableRLS() diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index ea566c5..9869f6d 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -32,3 +32,6 @@ export { plugins, pluginSettings, pluginPermissions } from './plugins.js' export { modNotes } from './mod-notes.js' export { topicNotices } from './topic-notices.js' export { modWarnings } from './mod-warnings.js' +export { communityRules } from './community-rules.js' +export { communityRuleVersions } from './community-rule-versions.js' +export { moderationActionRules } from './moderation-action-rules.js' diff --git a/src/db/schema/moderation-action-rules.ts b/src/db/schema/moderation-action-rules.ts new file mode 100644 index 0000000..6deeac9 --- /dev/null +++ b/src/db/schema/moderation-action-rules.ts @@ -0,0 +1,31 @@ +import { pgTable, pgPolicy, text, index, serial, integer, check } from 'drizzle-orm/pg-core' +import { sql } from 'drizzle-orm' +import { appRole } from './roles.js' + +export const moderationActionRules = pgTable( + 'moderation_action_rules', + { + id: serial('id').primaryKey(), + warningId: integer('warning_id'), + moderationActionId: integer('moderation_action_id'), + ruleVersionId: integer('rule_version_id').notNull(), + communityDid: text('community_did').notNull(), + }, + (table) => [ + index('mod_action_rules_warning_id_idx').on(table.warningId), + index('mod_action_rules_moderation_action_id_idx').on(table.moderationActionId), + index('mod_action_rules_rule_version_id_idx').on(table.ruleVersionId), + index('mod_action_rules_community_did_idx').on(table.communityDid), + check( + 'exactly_one_parent', + sql`(warning_id IS NOT NULL AND moderation_action_id IS NULL) OR (warning_id IS NULL AND moderation_action_id IS NOT NULL)` + ), + pgPolicy('tenant_isolation', { + as: 'permissive', + to: appRole, + for: 'all', + using: sql`community_did = current_setting('app.current_community_did', true)`, + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, + }), + ] +).enableRLS() diff --git a/src/routes/community-rules.ts b/src/routes/community-rules.ts new file mode 100644 index 0000000..aa98651 --- /dev/null +++ b/src/routes/community-rules.ts @@ -0,0 +1,559 @@ +import { eq, and, desc, isNull, sql } from 'drizzle-orm' +import { requireCommunityDid } from '../middleware/community-resolver.js' +import type { FastifyPluginCallback } from 'fastify' +import { notFound, badRequest, errorResponseSchema } from '../lib/api-errors.js' +import { + createRuleSchema, + updateRuleSchema, + reorderRulesSchema, + ruleVersionsQuerySchema, +} from '../validation/community-rules.js' +import { communityRules } from '../db/schema/community-rules.js' +import { communityRuleVersions } from '../db/schema/community-rule-versions.js' +import { createRequireAdmin } from '../auth/require-admin.js' + +// --------------------------------------------------------------------------- +// OpenAPI JSON Schema definitions +// --------------------------------------------------------------------------- + +const ruleJsonSchema = { + type: 'object' as const, + properties: { + id: { type: 'number' as const }, + title: { type: 'string' as const }, + description: { type: 'string' as const }, + displayOrder: { type: 'number' as const }, + createdAt: { type: 'string' as const, format: 'date-time' as const }, + updatedAt: { type: 'string' as const, format: 'date-time' as const }, + archivedAt: { type: ['string', 'null'] as const }, + }, +} + +const ruleVersionJsonSchema = { + type: 'object' as const, + properties: { + id: { type: 'number' as const }, + ruleId: { type: 'number' as const }, + title: { type: 'string' as const }, + description: { type: 'string' as const }, + createdAt: { type: 'string' as const, format: 'date-time' as const }, + }, +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function serializeRule(row: typeof communityRules.$inferSelect) { + return { + id: row.id, + title: row.title, + description: row.description, + displayOrder: row.displayOrder, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + archivedAt: row.archivedAt?.toISOString() ?? null, + } +} + +function serializeRuleVersion(row: typeof communityRuleVersions.$inferSelect) { + return { + id: row.id, + ruleId: row.ruleId, + title: row.title, + description: row.description, + createdAt: row.createdAt.toISOString(), + } +} + +function encodeCursor(createdAt: string, id: number): string { + return Buffer.from(JSON.stringify({ createdAt, id })).toString('base64') +} + +function decodeCursor(cursor: string): { createdAt: string; id: number } | null { + try { + const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8')) as Record< + string, + unknown + > + if (typeof decoded.createdAt === 'string' && typeof decoded.id === 'number') { + return { createdAt: decoded.createdAt, id: decoded.id } + } + return null + } catch { + return null + } +} + +// --------------------------------------------------------------------------- +// Community rules routes plugin +// --------------------------------------------------------------------------- + +export function communityRulesRoutes(): FastifyPluginCallback { + return (app, _opts, done) => { + const { db, authMiddleware } = app + const requireAdmin = createRequireAdmin(db, authMiddleware, app.log) + + // ------------------------------------------------------------------- + // GET /api/communities/:did/rules (public) + // ------------------------------------------------------------------- + app.get( + '/api/communities/:did/rules', + { + schema: { + tags: ['Community Rules'], + summary: 'List active community rules', + params: { + type: 'object', + required: ['did'], + properties: { did: { type: 'string' } }, + }, + response: { + 200: { + type: 'object', + properties: { + data: { + type: 'array', + items: ruleJsonSchema, + }, + }, + }, + }, + }, + }, + async (request, reply) => { + const { did } = request.params as { did: string } + + const rows = await db + .select() + .from(communityRules) + .where(and(eq(communityRules.communityDid, did), isNull(communityRules.archivedAt))) + .orderBy(communityRules.displayOrder) + + return reply.status(200).send({ + data: rows.map(serializeRule), + }) + } + ) + + // ------------------------------------------------------------------- + // POST /api/communities/:did/rules (admin only) + // ------------------------------------------------------------------- + app.post( + '/api/communities/:did/rules', + { + preHandler: [requireAdmin], + schema: { + tags: ['Community Rules'], + summary: 'Create a community rule', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['did'], + properties: { did: { type: 'string' } }, + }, + body: { + type: 'object', + required: ['title', 'description'], + properties: { + title: { type: 'string', maxLength: 200 }, + description: { type: 'string' }, + }, + }, + response: { + 201: ruleJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const communityDid = requireCommunityDid(request) + const parsed = createRuleSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid input: title and description are required') + } + + const { title, description } = parsed.data + + // Determine next display order + const maxOrderRows = await db + .select({ maxOrder: sql`COALESCE(MAX(${communityRules.displayOrder}), -1)` }) + .from(communityRules) + .where(eq(communityRules.communityDid, communityDid)) + + const nextOrder = (maxOrderRows[0]?.maxOrder ?? -1) + 1 + + const created = await db.transaction(async (tx) => { + const ruleRows = await tx + .insert(communityRules) + .values({ + communityDid, + title, + description, + displayOrder: nextOrder, + }) + .returning() + + const rule = ruleRows[0] + if (!rule) { + throw badRequest('Failed to create rule') + } + + // Create initial version + await tx.insert(communityRuleVersions).values({ + ruleId: rule.id, + title, + description, + }) + + return rule + }) + + app.log.info({ ruleId: created.id, communityDid }, 'Community rule created') + + return reply.status(201).send(serializeRule(created)) + } + ) + + // ------------------------------------------------------------------- + // PUT /api/communities/:did/rules/:id (admin only) + // ------------------------------------------------------------------- + app.put( + '/api/communities/:did/rules/:id', + { + preHandler: [requireAdmin], + schema: { + tags: ['Community Rules'], + summary: 'Update a community rule (creates a new version)', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['did', 'id'], + properties: { + did: { type: 'string' }, + id: { type: 'string' }, + }, + }, + body: { + type: 'object', + required: ['title', 'description'], + properties: { + title: { type: 'string', maxLength: 200 }, + description: { type: 'string' }, + }, + }, + response: { + 200: ruleJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const communityDid = requireCommunityDid(request) + const { id: idStr } = request.params as { id: string } + const ruleId = parseInt(idStr, 10) + if (Number.isNaN(ruleId)) { + throw badRequest('Invalid rule ID') + } + + const parsed = updateRuleSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid input: title and description are required') + } + + const { title, description } = parsed.data + + // Verify rule exists and belongs to this community + const existingRows = await db + .select() + .from(communityRules) + .where( + and( + eq(communityRules.id, ruleId), + eq(communityRules.communityDid, communityDid), + isNull(communityRules.archivedAt) + ) + ) + + const existing = existingRows[0] + if (!existing) { + throw notFound('Rule not found') + } + + const updated = await db.transaction(async (tx) => { + const updatedRows = await tx + .update(communityRules) + .set({ + title, + description, + updatedAt: new Date(), + }) + .where(eq(communityRules.id, ruleId)) + .returning() + + // Create new version snapshot + await tx.insert(communityRuleVersions).values({ + ruleId, + title, + description, + }) + + return updatedRows[0] + }) + + if (!updated) { + throw notFound('Rule not found') + } + + app.log.info({ ruleId, communityDid }, 'Community rule updated (new version created)') + + return reply.status(200).send(serializeRule(updated)) + } + ) + + // ------------------------------------------------------------------- + // DELETE /api/communities/:did/rules/:id (admin only, soft-delete) + // ------------------------------------------------------------------- + app.delete( + '/api/communities/:did/rules/:id', + { + preHandler: [requireAdmin], + schema: { + tags: ['Community Rules'], + summary: 'Archive a community rule (soft-delete)', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['did', 'id'], + properties: { + did: { type: 'string' }, + id: { type: 'string' }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' as const }, + }, + }, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const communityDid = requireCommunityDid(request) + const { id: idStr } = request.params as { id: string } + const ruleId = parseInt(idStr, 10) + if (Number.isNaN(ruleId)) { + throw badRequest('Invalid rule ID') + } + + // Verify rule exists and is not already archived + const existingRows = await db + .select() + .from(communityRules) + .where( + and( + eq(communityRules.id, ruleId), + eq(communityRules.communityDid, communityDid), + isNull(communityRules.archivedAt) + ) + ) + + if (!existingRows[0]) { + throw notFound('Rule not found') + } + + await db + .update(communityRules) + .set({ archivedAt: new Date() }) + .where(eq(communityRules.id, ruleId)) + + app.log.info({ ruleId, communityDid }, 'Community rule archived') + + return reply.status(200).send({ success: true }) + } + ) + + // ------------------------------------------------------------------- + // PUT /api/communities/:did/rules/reorder (admin only) + // ------------------------------------------------------------------- + app.put( + '/api/communities/:did/rules/reorder', + { + preHandler: [requireAdmin], + schema: { + tags: ['Community Rules'], + summary: 'Reorder community rules', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['did'], + properties: { did: { type: 'string' } }, + }, + body: { + type: 'object', + required: ['order'], + properties: { + order: { + type: 'array', + items: { + type: 'object', + required: ['id', 'displayOrder'], + properties: { + id: { type: 'number' }, + displayOrder: { type: 'number' }, + }, + }, + }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' as const }, + }, + }, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const communityDid = requireCommunityDid(request) + const parsed = reorderRulesSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest('Invalid input: order array is required') + } + + const { order } = parsed.data + + await db.transaction(async (tx) => { + for (const item of order) { + await tx + .update(communityRules) + .set({ displayOrder: item.displayOrder }) + .where( + and(eq(communityRules.id, item.id), eq(communityRules.communityDid, communityDid)) + ) + } + }) + + app.log.info({ communityDid, ruleCount: order.length }, 'Community rules reordered') + + return reply.status(200).send({ success: true }) + } + ) + + // ------------------------------------------------------------------- + // GET /api/communities/:did/rules/:id/versions (admin only) + // ------------------------------------------------------------------- + app.get( + '/api/communities/:did/rules/:id/versions', + { + preHandler: [requireAdmin], + schema: { + tags: ['Community Rules'], + summary: 'Get version history for a community rule', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['did', 'id'], + properties: { + did: { type: 'string' }, + id: { type: 'string' }, + }, + }, + querystring: { + type: 'object', + properties: { + cursor: { type: 'string' }, + limit: { type: 'number', default: 25 }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + data: { + type: 'array', + items: ruleVersionJsonSchema, + }, + cursor: { type: ['string', 'null'] as const }, + }, + }, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const communityDid = requireCommunityDid(request) + const { id: idStr } = request.params as { id: string } + const ruleId = parseInt(idStr, 10) + if (Number.isNaN(ruleId)) { + throw badRequest('Invalid rule ID') + } + + const queryParsed = ruleVersionsQuerySchema.safeParse(request.query) + if (!queryParsed.success) { + throw badRequest('Invalid query parameters') + } + const { cursor, limit } = queryParsed.data + + // Verify rule exists and belongs to this community + const ruleRows = await db + .select() + .from(communityRules) + .where(and(eq(communityRules.id, ruleId), eq(communityRules.communityDid, communityDid))) + + if (!ruleRows[0]) { + throw notFound('Rule not found') + } + + const conditions = [eq(communityRuleVersions.ruleId, ruleId)] + if (cursor) { + const decoded = decodeCursor(cursor) + if (decoded) { + conditions.push( + sql`(${communityRuleVersions.createdAt}, ${communityRuleVersions.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})` + ) + } + } + + const fetchLimit = limit + 1 + const rows = await db + .select() + .from(communityRuleVersions) + .where(and(...conditions)) + .orderBy(desc(communityRuleVersions.createdAt)) + .limit(fetchLimit) + + const hasMore = rows.length > limit + const data = rows.slice(0, limit) + const lastItem = data[data.length - 1] + const nextCursor = + hasMore && lastItem ? encodeCursor(lastItem.createdAt.toISOString(), lastItem.id) : null + + return reply.status(200).send({ + data: data.map(serializeRuleVersion), + cursor: nextCursor, + }) + } + ) + + done() + } +} diff --git a/src/validation/community-rules.ts b/src/validation/community-rules.ts new file mode 100644 index 0000000..7b657c8 --- /dev/null +++ b/src/validation/community-rules.ts @@ -0,0 +1,31 @@ +import { z } from 'zod' + +// --------------------------------------------------------------------------- +// Community rules schemas +// --------------------------------------------------------------------------- + +export const createRuleSchema = z.object({ + title: z.string().min(1).max(200), + description: z.string().min(1), +}) + +export const updateRuleSchema = z.object({ + title: z.string().min(1).max(200), + description: z.string().min(1), +}) + +export const reorderRulesSchema = z.object({ + order: z + .array( + z.object({ + id: z.number().int().positive(), + displayOrder: z.number().int().min(0), + }) + ) + .min(1), +}) + +export const ruleVersionsQuerySchema = z.object({ + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).default(25), +}) diff --git a/tests/unit/routes/community-rules.test.ts b/tests/unit/routes/community-rules.test.ts new file mode 100644 index 0000000..bc0cf8d --- /dev/null +++ b/tests/unit/routes/community-rules.test.ts @@ -0,0 +1,590 @@ +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' +import type { Env } from '../../../src/config/env.js' +import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' +import type { SessionService } from '../../../src/auth/session.js' +import type { SetupService } from '../../../src/setup/service.js' +import { type DbChain, createChainableProxy, createMockDb } from '../../helpers/mock-db.js' + +// --------------------------------------------------------------------------- +// Mock requireAdmin module (must be before importing routes) +// --------------------------------------------------------------------------- + +const mockRequireAdmin = vi.fn<(request: FastifyRequest, reply: FastifyReply) => Promise>() + +vi.mock('../../../src/auth/require-admin.js', () => ({ + createRequireAdmin: () => mockRequireAdmin, +})) + +// Import routes AFTER mocking +import { communityRulesRoutes } from '../../../src/routes/community-rules.js' + +// --------------------------------------------------------------------------- +// Mock env +// --------------------------------------------------------------------------- + +const mockEnv = { + COMMUNITY_DID: 'did:plc:community123', + RATE_LIMIT_WRITE: 10, + RATE_LIMIT_READ_ANON: 100, + RATE_LIMIT_READ_AUTH: 300, +} as Env + +// --------------------------------------------------------------------------- +// Test constants +// --------------------------------------------------------------------------- + +const ADMIN_DID = 'did:plc:admin1' +const ADMIN_HANDLE = 'admin.bsky.team' +const ADMIN_SID = 'a'.repeat(64) +const COMMUNITY_DID = 'did:plc:community123' +const TEST_NOW = '2026-03-10T12:00:00.000Z' + +// --------------------------------------------------------------------------- +// Mock user builders +// --------------------------------------------------------------------------- + +function adminUser(overrides?: Partial): RequestUser { + return { + did: ADMIN_DID, + handle: ADMIN_HANDLE, + sid: ADMIN_SID, + ...overrides, + } +} + +// --------------------------------------------------------------------------- +// Chainable mock DB +// --------------------------------------------------------------------------- + +const mockDb = createMockDb() + +let insertChain: DbChain +let selectChain: DbChain +let updateChain: DbChain + +function resetAllDbMocks(): void { + insertChain = createChainableProxy() + selectChain = createChainableProxy([]) + updateChain = createChainableProxy([]) + mockDb.insert.mockReturnValue(insertChain) + mockDb.select.mockReturnValue(selectChain) + mockDb.update.mockReturnValue(updateChain) + mockDb.delete.mockReturnValue(createChainableProxy()) + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally async mock for Drizzle transaction + mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockDb) => Promise) => { + return await fn(mockDb) + }) +} + +// --------------------------------------------------------------------------- +// Auth middleware mocks +// --------------------------------------------------------------------------- + +function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { + return { + requireAuth: async (request, reply) => { + if (!user) { + await reply.status(401).send({ error: 'Authentication required' }) + return + } + request.user = user + }, + optionalAuth: (request, _reply) => { + if (user) { + request.user = user + } + return Promise.resolve() + }, + } +} + +// --------------------------------------------------------------------------- +// Sample data builders +// --------------------------------------------------------------------------- + +function sampleRule(overrides?: Record) { + return { + id: 1, + communityDid: COMMUNITY_DID, + title: 'Be respectful', + description: 'Treat all members with respect and courtesy.', + displayOrder: 0, + createdAt: new Date(TEST_NOW), + updatedAt: new Date(TEST_NOW), + archivedAt: null, + ...overrides, + } +} + +function sampleRuleVersion(overrides?: Record) { + return { + id: 1, + ruleId: 1, + title: 'Be respectful', + description: 'Treat all members with respect and courtesy.', + createdAt: new Date(TEST_NOW), + ...overrides, + } +} + +// --------------------------------------------------------------------------- +// Helper: build app with mocked deps +// --------------------------------------------------------------------------- + +async function buildTestApp(user?: RequestUser): Promise { + const app = Fastify({ logger: false }) + + const authMiddleware = createMockAuthMiddleware(user) + + app.decorate('db', mockDb as never) + app.decorate('env', mockEnv) + app.decorate('authMiddleware', authMiddleware) + app.decorate('firehose', {} as never) + app.decorate('oauthClient', {} as never) + app.decorate('sessionService', {} as SessionService) + app.decorate('setupService', {} as SetupService) + app.decorate('cache', {} as never) + app.decorateRequest('user', undefined as RequestUser | undefined) + app.decorateRequest('communityDid', undefined as string | undefined) + app.addHook('onRequest', (request, _reply, done) => { + request.communityDid = COMMUNITY_DID + done() + }) + + await app.register(communityRulesRoutes()) + await app.ready() + + return app +} + +// =========================================================================== +// Test suite +// =========================================================================== + +describe('community rules routes', () => { + // ========================================================================= + // GET /api/communities/:did/rules + // ========================================================================= + + describe('GET /api/communities/:did/rules', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp() + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('should return active rules in display order', async () => { + const rules = [ + sampleRule({ id: 1, displayOrder: 0 }), + sampleRule({ id: 2, title: 'No spam', displayOrder: 1 }), + ] + selectChain = createChainableProxy(rules) + mockDb.select.mockReturnValue(selectChain) + + const res = await app.inject({ + method: 'GET', + url: `/api/communities/${COMMUNITY_DID}/rules`, + }) + + expect(res.statusCode).toBe(200) + const body = JSON.parse(res.body) as { data: unknown[] } + expect(body.data).toHaveLength(2) + }) + + it('should return empty array when no rules exist', async () => { + selectChain = createChainableProxy([]) + mockDb.select.mockReturnValue(selectChain) + + const res = await app.inject({ + method: 'GET', + url: `/api/communities/${COMMUNITY_DID}/rules`, + }) + + expect(res.statusCode).toBe(200) + const body = JSON.parse(res.body) as { data: unknown[] } + expect(body.data).toHaveLength(0) + }) + + it('should be accessible without authentication', async () => { + selectChain = createChainableProxy([]) + mockDb.select.mockReturnValue(selectChain) + + const res = await app.inject({ + method: 'GET', + url: `/api/communities/${COMMUNITY_DID}/rules`, + }) + + expect(res.statusCode).toBe(200) + }) + }) + + // ========================================================================= + // POST /api/communities/:did/rules + // ========================================================================= + + describe('POST /api/communities/:did/rules', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(adminUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + + mockRequireAdmin.mockImplementation((request) => { + request.user = adminUser() + return Promise.resolve() + }) + }) + + it('should create a rule with initial version', async () => { + const created = sampleRule() + // First select: max display order + selectChain = createChainableProxy([{ maxOrder: 0 }]) + mockDb.select.mockReturnValue(selectChain) + // Transaction inserts + insertChain = createChainableProxy() + insertChain.returning.mockResolvedValueOnce([created]) + mockDb.insert.mockReturnValue(insertChain) + + const res = await app.inject({ + method: 'POST', + url: `/api/communities/${COMMUNITY_DID}/rules`, + payload: { title: 'Be respectful', description: 'Treat all members with respect.' }, + }) + + expect(res.statusCode).toBe(201) + const body = JSON.parse(res.body) as { id: number; title: string } + expect(body.title).toBe('Be respectful') + }) + + it('should reject missing title', async () => { + const res = await app.inject({ + method: 'POST', + url: `/api/communities/${COMMUNITY_DID}/rules`, + payload: { description: 'Some description' }, + }) + + expect(res.statusCode).toBe(400) + }) + + it('should reject missing description', async () => { + const res = await app.inject({ + method: 'POST', + url: `/api/communities/${COMMUNITY_DID}/rules`, + payload: { title: 'A rule' }, + }) + + expect(res.statusCode).toBe(400) + }) + + it('should reject title exceeding 200 chars', async () => { + const res = await app.inject({ + method: 'POST', + url: `/api/communities/${COMMUNITY_DID}/rules`, + payload: { title: 'x'.repeat(201), description: 'desc' }, + }) + + expect(res.statusCode).toBe(400) + }) + + it('should require admin access', async () => { + mockRequireAdmin.mockImplementation(async (_request, reply) => { + await reply.status(403).send({ error: 'Admin access required' }) + }) + + const res = await app.inject({ + method: 'POST', + url: `/api/communities/${COMMUNITY_DID}/rules`, + payload: { title: 'A rule', description: 'desc' }, + }) + + expect(res.statusCode).toBe(403) + }) + }) + + // ========================================================================= + // PUT /api/communities/:did/rules/:id + // ========================================================================= + + describe('PUT /api/communities/:did/rules/:id', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(adminUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + + mockRequireAdmin.mockImplementation((request) => { + request.user = adminUser() + return Promise.resolve() + }) + }) + + it('should update a rule and create a new version', async () => { + const existing = sampleRule() + const updated = sampleRule({ title: 'Updated title', updatedAt: new Date() }) + + // First select: find existing rule + selectChain = createChainableProxy([existing]) + mockDb.select.mockReturnValue(selectChain) + // Transaction: update + insert version + updateChain = createChainableProxy() + updateChain.returning.mockResolvedValueOnce([updated]) + mockDb.update.mockReturnValue(updateChain) + insertChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) + + const res = await app.inject({ + method: 'PUT', + url: `/api/communities/${COMMUNITY_DID}/rules/1`, + payload: { title: 'Updated title', description: 'Updated description' }, + }) + + expect(res.statusCode).toBe(200) + const body = JSON.parse(res.body) as { title: string } + expect(body.title).toBe('Updated title') + }) + + it('should return 404 for non-existent rule', async () => { + selectChain = createChainableProxy([]) + mockDb.select.mockReturnValue(selectChain) + + const res = await app.inject({ + method: 'PUT', + url: `/api/communities/${COMMUNITY_DID}/rules/999`, + payload: { title: 'Updated', description: 'Updated' }, + }) + + expect(res.statusCode).toBe(404) + }) + + it('should reject invalid rule ID', async () => { + const res = await app.inject({ + method: 'PUT', + url: `/api/communities/${COMMUNITY_DID}/rules/abc`, + payload: { title: 'Updated', description: 'Updated' }, + }) + + expect(res.statusCode).toBe(400) + }) + }) + + // ========================================================================= + // DELETE /api/communities/:did/rules/:id + // ========================================================================= + + describe('DELETE /api/communities/:did/rules/:id', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(adminUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + + mockRequireAdmin.mockImplementation((request) => { + request.user = adminUser() + return Promise.resolve() + }) + }) + + it('should archive (soft-delete) a rule', async () => { + selectChain = createChainableProxy([sampleRule()]) + mockDb.select.mockReturnValue(selectChain) + + const res = await app.inject({ + method: 'DELETE', + url: `/api/communities/${COMMUNITY_DID}/rules/1`, + }) + + expect(res.statusCode).toBe(200) + const body = JSON.parse(res.body) as { success: boolean } + expect(body.success).toBe(true) + }) + + it('should return 404 for non-existent rule', async () => { + selectChain = createChainableProxy([]) + mockDb.select.mockReturnValue(selectChain) + + const res = await app.inject({ + method: 'DELETE', + url: `/api/communities/${COMMUNITY_DID}/rules/999`, + }) + + expect(res.statusCode).toBe(404) + }) + + it('should require admin access', async () => { + mockRequireAdmin.mockImplementation(async (_request, reply) => { + await reply.status(403).send({ error: 'Admin access required' }) + }) + + const res = await app.inject({ + method: 'DELETE', + url: `/api/communities/${COMMUNITY_DID}/rules/1`, + }) + + expect(res.statusCode).toBe(403) + }) + }) + + // ========================================================================= + // PUT /api/communities/:did/rules/reorder + // ========================================================================= + + describe('PUT /api/communities/:did/rules/reorder', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(adminUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + + mockRequireAdmin.mockImplementation((request) => { + request.user = adminUser() + return Promise.resolve() + }) + }) + + it('should reorder rules', async () => { + const res = await app.inject({ + method: 'PUT', + url: `/api/communities/${COMMUNITY_DID}/rules/reorder`, + payload: { + order: [ + { id: 1, displayOrder: 1 }, + { id: 2, displayOrder: 0 }, + ], + }, + }) + + expect(res.statusCode).toBe(200) + const body = JSON.parse(res.body) as { success: boolean } + expect(body.success).toBe(true) + }) + + it('should reject empty order array', async () => { + const res = await app.inject({ + method: 'PUT', + url: `/api/communities/${COMMUNITY_DID}/rules/reorder`, + payload: { order: [] }, + }) + + expect(res.statusCode).toBe(400) + }) + }) + + // ========================================================================= + // GET /api/communities/:did/rules/:id/versions + // ========================================================================= + + describe('GET /api/communities/:did/rules/:id/versions', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(adminUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + + mockRequireAdmin.mockImplementation((request) => { + request.user = adminUser() + return Promise.resolve() + }) + }) + + it('should return version history for a rule', async () => { + const rule = sampleRule() + const versions = [ + sampleRuleVersion({ id: 2, title: 'Updated title' }), + sampleRuleVersion({ id: 1 }), + ] + + // First select: verify rule exists + const ruleSelectChain = createChainableProxy([rule]) + // Second select: get versions + const versionSelectChain = createChainableProxy(versions) + + mockDb.select.mockReturnValueOnce(ruleSelectChain).mockReturnValueOnce(versionSelectChain) + + const res = await app.inject({ + method: 'GET', + url: `/api/communities/${COMMUNITY_DID}/rules/1/versions`, + }) + + expect(res.statusCode).toBe(200) + const body = JSON.parse(res.body) as { data: unknown[]; cursor: string | null } + expect(body.data).toHaveLength(2) + expect(body.cursor).toBeNull() + }) + + it('should return 404 for non-existent rule', async () => { + selectChain = createChainableProxy([]) + mockDb.select.mockReturnValue(selectChain) + + const res = await app.inject({ + method: 'GET', + url: `/api/communities/${COMMUNITY_DID}/rules/999/versions`, + }) + + expect(res.statusCode).toBe(404) + }) + + it('should require admin access', async () => { + mockRequireAdmin.mockImplementation(async (_request, reply) => { + await reply.status(403).send({ error: 'Admin access required' }) + }) + + const res = await app.inject({ + method: 'GET', + url: `/api/communities/${COMMUNITY_DID}/rules/1/versions`, + }) + + expect(res.statusCode).toBe(403) + }) + }) +}) diff --git a/tests/unit/validation/community-rules.test.ts b/tests/unit/validation/community-rules.test.ts new file mode 100644 index 0000000..c31ac1f --- /dev/null +++ b/tests/unit/validation/community-rules.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from 'vitest' +import { + createRuleSchema, + updateRuleSchema, + reorderRulesSchema, + ruleVersionsQuerySchema, +} from '../../../src/validation/community-rules.js' + +describe('community rules validation schemas', () => { + describe('createRuleSchema', () => { + it('should accept valid rule', () => { + const result = createRuleSchema.safeParse({ + title: 'Be respectful', + description: 'Treat all members with respect and courtesy.', + }) + expect(result.success).toBe(true) + }) + + it('should reject empty title', () => { + const result = createRuleSchema.safeParse({ + title: '', + description: 'Some description', + }) + expect(result.success).toBe(false) + }) + + it('should reject title exceeding 200 chars', () => { + const result = createRuleSchema.safeParse({ + title: 'x'.repeat(201), + description: 'Some description', + }) + expect(result.success).toBe(false) + }) + + it('should reject empty description', () => { + const result = createRuleSchema.safeParse({ + title: 'A rule', + description: '', + }) + expect(result.success).toBe(false) + }) + + it('should reject missing title', () => { + const result = createRuleSchema.safeParse({ + description: 'Some description', + }) + expect(result.success).toBe(false) + }) + + it('should reject missing description', () => { + const result = createRuleSchema.safeParse({ + title: 'A rule', + }) + expect(result.success).toBe(false) + }) + }) + + describe('updateRuleSchema', () => { + it('should accept valid update', () => { + const result = updateRuleSchema.safeParse({ + title: 'Updated title', + description: 'Updated description', + }) + expect(result.success).toBe(true) + }) + + it('should reject empty title', () => { + const result = updateRuleSchema.safeParse({ + title: '', + description: 'Updated description', + }) + expect(result.success).toBe(false) + }) + }) + + describe('reorderRulesSchema', () => { + it('should accept valid order array', () => { + const result = reorderRulesSchema.safeParse({ + order: [ + { id: 1, displayOrder: 0 }, + { id: 2, displayOrder: 1 }, + ], + }) + expect(result.success).toBe(true) + }) + + it('should reject empty order array', () => { + const result = reorderRulesSchema.safeParse({ + order: [], + }) + expect(result.success).toBe(false) + }) + + it('should reject negative id', () => { + const result = reorderRulesSchema.safeParse({ + order: [{ id: -1, displayOrder: 0 }], + }) + expect(result.success).toBe(false) + }) + + it('should reject negative displayOrder', () => { + const result = reorderRulesSchema.safeParse({ + order: [{ id: 1, displayOrder: -1 }], + }) + expect(result.success).toBe(false) + }) + }) + + describe('ruleVersionsQuerySchema', () => { + it('should accept empty query (defaults)', () => { + const result = ruleVersionsQuerySchema.safeParse({}) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.limit).toBe(25) + } + }) + + it('should accept cursor and limit', () => { + const result = ruleVersionsQuerySchema.safeParse({ + cursor: 'abc123', + limit: '10', + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.limit).toBe(10) + } + }) + + it('should reject limit exceeding 100', () => { + const result = ruleVersionsQuerySchema.safeParse({ + limit: '101', + }) + expect(result.success).toBe(false) + }) + }) +}) -- 2.51.2