@nexpress/core 0.3.7 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{audit-43OLHR3U.js → audit-ZLNKBIDO.js} +3 -3
- package/dist/auth.js +4 -4
- package/dist/{can-UJ2NAOIR.js → can-U5F4JBZ7.js} +2 -2
- package/dist/{chunk-OMGQZ4Q5.js → chunk-2OWUHCFY.js} +2 -2
- package/dist/{chunk-OMGQZ4Q5.js.map → chunk-2OWUHCFY.js.map} +1 -1
- package/dist/{chunk-ELK6AVW5.js → chunk-2X3GBJOT.js} +2 -2
- package/dist/{chunk-HNX7COHQ.js → chunk-3SW4L3DL.js} +12 -12
- package/dist/chunk-3SW4L3DL.js.map +1 -0
- package/dist/{chunk-ML2E3P3X.js → chunk-5C22NDW4.js} +2 -2
- package/dist/chunk-5C22NDW4.js.map +1 -0
- package/dist/{chunk-RKM4GDWM.js → chunk-6MRTH734.js} +1 -1
- package/dist/chunk-6MRTH734.js.map +1 -0
- package/dist/{chunk-PW43RCJK.js → chunk-6OUWW6JF.js} +2 -2
- package/dist/{chunk-QBIJZZ5V.js → chunk-CGLJBRRX.js} +2 -2
- package/dist/chunk-CGLJBRRX.js.map +1 -0
- package/dist/{chunk-2VZZ7M26.js → chunk-EAYUAXW3.js} +3 -3
- package/dist/chunk-EAYUAXW3.js.map +1 -0
- package/dist/{chunk-2N53KKIL.js → chunk-EWVXP3GP.js} +2 -2
- package/dist/{chunk-CAS4Z6IN.js → chunk-I4FSVEJK.js} +1 -1
- package/dist/chunk-I4FSVEJK.js.map +1 -0
- package/dist/{chunk-LN6NTH6E.js → chunk-K4CJ3KXB.js} +3 -3
- package/dist/chunk-K4CJ3KXB.js.map +1 -0
- package/dist/{chunk-B7DTNT4O.js → chunk-MWLSXK6Y.js} +2 -2
- package/dist/{chunk-NFHS7CFV.js → chunk-Q7MK5ZKG.js} +2 -2
- package/dist/{chunk-PUV3VZPD.js → chunk-QZ52U4ET.js} +2 -2
- package/dist/{chunk-2GXH7566.js → chunk-SJ7M2VCC.js} +10 -10
- package/dist/chunk-SJ7M2VCC.js.map +1 -0
- package/dist/{chunk-6UV2P5MW.js → chunk-TIWJVQOO.js} +3 -3
- package/dist/chunk-TIWJVQOO.js.map +1 -0
- package/dist/{chunk-MLXKZK6G.js → chunk-TSCXXBOM.js} +76 -28
- package/dist/chunk-TSCXXBOM.js.map +1 -0
- package/dist/{chunk-L6VG7IK6.js → chunk-VBVLYFSZ.js} +2 -2
- package/dist/chunk-VBVLYFSZ.js.map +1 -0
- package/dist/{chunk-RDTTK27V.js → chunk-XPD7EQML.js} +3 -3
- package/dist/chunk-XPD7EQML.js.map +1 -0
- package/dist/{chunk-RJ76SKWQ.js → chunk-XU2GJJ6Z.js} +1 -1
- package/dist/chunk-XU2GJJ6Z.js.map +1 -0
- package/dist/{chunk-WJJ5MBH5.js → chunk-YEOQJ7WW.js} +1 -1
- package/dist/chunk-YEOQJ7WW.js.map +1 -0
- package/dist/community.js +14 -14
- package/dist/{config-YHUEYQ66.js → config-YDGNUDKP.js} +5 -5
- package/dist/{digest-ZODDTXA2.js → digest-IWHMJPXI.js} +4 -4
- package/dist/{host-XBGYIQEE.js → host-HG4QGD3L.js} +4 -4
- package/dist/i18n.js +2 -2
- package/dist/index.js +21 -21
- package/dist/index.js.map +1 -1
- package/dist/{job-log-N3IGI4NA.js → job-log-UY6ERPQZ.js} +3 -3
- package/dist/jobs.js +3 -3
- package/dist/{logger-2WUTTELV.js → logger-6ZGEKEMK.js} +2 -2
- package/dist/media.js +3 -3
- package/dist/{mentions-U4JACYI6.js → mentions-LQRZWAGO.js} +2 -2
- package/dist/{mutes-MNQP6ACF.js → mutes-PQA6U5X7.js} +2 -2
- package/dist/{notification-prefs-H4HFVCL7.js → notification-prefs-62NX2GBF.js} +2 -2
- package/dist/observability.js +2 -2
- package/dist/{reputation-ICIXDGPM.js → reputation-5DJLDBZY.js} +3 -3
- package/dist/{scheduled-S6IO47JD.js → scheduled-C2IKVZVK.js} +5 -5
- package/dist/seo.js +4 -4
- package/dist/{settings-OZWM6L2K.js → settings-NBAP7E5E.js} +2 -2
- package/dist/{strings-4EWJYDOG.js → strings-O2M7VSKV.js} +3 -3
- package/package.json +1 -1
- package/dist/chunk-2GXH7566.js.map +0 -1
- package/dist/chunk-2VZZ7M26.js.map +0 -1
- package/dist/chunk-6UV2P5MW.js.map +0 -1
- package/dist/chunk-CAS4Z6IN.js.map +0 -1
- package/dist/chunk-HNX7COHQ.js.map +0 -1
- package/dist/chunk-L6VG7IK6.js.map +0 -1
- package/dist/chunk-LN6NTH6E.js.map +0 -1
- package/dist/chunk-ML2E3P3X.js.map +0 -1
- package/dist/chunk-MLXKZK6G.js.map +0 -1
- package/dist/chunk-QBIJZZ5V.js.map +0 -1
- package/dist/chunk-RDTTK27V.js.map +0 -1
- package/dist/chunk-RJ76SKWQ.js.map +0 -1
- package/dist/chunk-RKM4GDWM.js.map +0 -1
- package/dist/chunk-WJJ5MBH5.js.map +0 -1
- /package/dist/{audit-43OLHR3U.js.map → audit-ZLNKBIDO.js.map} +0 -0
- /package/dist/{can-UJ2NAOIR.js.map → can-U5F4JBZ7.js.map} +0 -0
- /package/dist/{chunk-ELK6AVW5.js.map → chunk-2X3GBJOT.js.map} +0 -0
- /package/dist/{chunk-PW43RCJK.js.map → chunk-6OUWW6JF.js.map} +0 -0
- /package/dist/{chunk-2N53KKIL.js.map → chunk-EWVXP3GP.js.map} +0 -0
- /package/dist/{chunk-B7DTNT4O.js.map → chunk-MWLSXK6Y.js.map} +0 -0
- /package/dist/{chunk-NFHS7CFV.js.map → chunk-Q7MK5ZKG.js.map} +0 -0
- /package/dist/{chunk-PUV3VZPD.js.map → chunk-QZ52U4ET.js.map} +0 -0
- /package/dist/{config-YHUEYQ66.js.map → config-YDGNUDKP.js.map} +0 -0
- /package/dist/{digest-ZODDTXA2.js.map → digest-IWHMJPXI.js.map} +0 -0
- /package/dist/{host-XBGYIQEE.js.map → host-HG4QGD3L.js.map} +0 -0
- /package/dist/{job-log-N3IGI4NA.js.map → job-log-UY6ERPQZ.js.map} +0 -0
- /package/dist/{logger-2WUTTELV.js.map → logger-6ZGEKEMK.js.map} +0 -0
- /package/dist/{mentions-U4JACYI6.js.map → mentions-LQRZWAGO.js.map} +0 -0
- /package/dist/{mutes-MNQP6ACF.js.map → mutes-PQA6U5X7.js.map} +0 -0
- /package/dist/{notification-prefs-H4HFVCL7.js.map → notification-prefs-62NX2GBF.js.map} +0 -0
- /package/dist/{reputation-ICIXDGPM.js.map → reputation-5DJLDBZY.js.map} +0 -0
- /package/dist/{scheduled-S6IO47JD.js.map → scheduled-C2IKVZVK.js.map} +0 -0
- /package/dist/{settings-OZWM6L2K.js.map → settings-NBAP7E5E.js.map} +0 -0
- /package/dist/{strings-4EWJYDOG.js.map → strings-O2M7VSKV.js.map} +0 -0
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from "./chunk-OROPGO65.js";
|
|
7
7
|
import {
|
|
8
8
|
getLogger
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-Q7MK5ZKG.js";
|
|
10
10
|
import {
|
|
11
11
|
getDb
|
|
12
12
|
} from "./chunk-XANPEOJC.js";
|
|
@@ -170,7 +170,7 @@ async function uploadMedia(file, uploader, folderId) {
|
|
|
170
170
|
return { id, status: "processing" };
|
|
171
171
|
}
|
|
172
172
|
async function assertMemberUploadQuota(memberId, txDb) {
|
|
173
|
-
const { getCommunitySettings } = await import("./settings-
|
|
173
|
+
const { getCommunitySettings } = await import("./settings-NBAP7E5E.js");
|
|
174
174
|
const { NpRateLimitError } = await import("./errors-5OS3S2J3.js");
|
|
175
175
|
const settings = await getCommunitySettings();
|
|
176
176
|
const { perDay, total } = settings.memberUploadQuota;
|
|
@@ -535,4 +535,4 @@ export {
|
|
|
535
535
|
listMedia,
|
|
536
536
|
cleanupDeletedMedia
|
|
537
537
|
};
|
|
538
|
-
//# sourceMappingURL=chunk-
|
|
538
|
+
//# sourceMappingURL=chunk-EAYUAXW3.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/media/processor.ts","../src/media/service.ts"],"sourcesContent":["import sharp from \"sharp\";\n\nimport type { NpImageSize } from \"../config/types.js\";\n\nexport interface NpProcessedImageVariant {\n name: string;\n buffer: Buffer;\n width: number;\n height: number;\n size: number;\n}\n\nexport interface NpProcessedImageSourceMetadata {\n width: number | null;\n height: number | null;\n format: string | null;\n}\n\nexport interface NpProcessedImageResult {\n source: NpProcessedImageSourceMetadata;\n variants: NpProcessedImageVariant[];\n}\n\nexport const DEFAULT_IMAGE_SIZES: NpImageSize[] = [\n { name: \"thumbnail\", width: 300 },\n { name: \"small\", width: 600 },\n { name: \"medium\", width: 900 },\n { name: \"large\", width: 1400 },\n { name: \"xlarge\", width: 1920 },\n { name: \"og\", width: 1200, height: 630, crop: \"center\" },\n];\n\nexport async function processImage(\n inputBuffer: Buffer,\n sizes: NpImageSize[],\n options: { format?: string; quality?: number } = {},\n): Promise<NpProcessedImageResult> {\n const format = options.format ?? \"webp\";\n const quality = options.quality ?? 80;\n const sourceImage = sharp(inputBuffer).autoOrient();\n const metadata = await sourceImage.metadata();\n\n const variants = await Promise.all(\n sizes.map(async (size) => {\n const resized = size.height\n ? sourceImage.clone().resize({\n width: size.width,\n height: size.height,\n fit: \"cover\",\n position: resolveCropPosition(size.crop),\n })\n : sourceImage.clone().resize({\n width: size.width,\n fit: \"inside\",\n withoutEnlargement: true,\n });\n\n const formatted = applyFormat(resized, format, quality);\n const { data, info } = await formatted.toBuffer({ resolveWithObject: true });\n\n return {\n name: size.name,\n buffer: data,\n width: info.width,\n height: info.height,\n size: info.size ?? data.byteLength,\n };\n }),\n );\n\n return {\n source: {\n width: metadata.width ?? null,\n height: metadata.height ?? null,\n format: metadata.format ?? null,\n },\n variants,\n };\n}\n\nfunction applyFormat(\n image: sharp.Sharp,\n format: string,\n quality: number,\n): sharp.Sharp {\n switch (format) {\n case \"avif\":\n return image.avif({ quality });\n case \"jpeg\":\n return image.jpeg({ quality });\n case \"png\":\n return image.png({ quality });\n case \"webp\":\n default:\n return image.webp({ quality });\n }\n}\n\nfunction resolveCropPosition(crop?: NpImageSize[\"crop\"]): sharp.Gravity | number {\n switch (crop) {\n case \"top\":\n return \"top\";\n case \"bottom\":\n return \"bottom\";\n case \"left\":\n return \"left\";\n case \"right\":\n return \"right\";\n case \"center\":\n return \"centre\";\n default:\n return sharp.strategy.attention;\n }\n}\n","import { createHash, randomUUID } from \"node:crypto\";\nimport { extname } from \"node:path\";\nimport { buffer as consumeBuffer } from \"node:stream/consumers\";\nimport { Readable } from \"node:stream\";\n\nimport { and, count, desc, eq, gte, ilike, inArray, isNotNull, isNull, lt, or, sql } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\nimport type { PgTable } from \"drizzle-orm/pg-core\";\n\nimport type { NpFindResult, NpImageSize } from \"../config/types.js\";\nimport { readEnvPositiveInt } from \"../config/env.js\";\nimport { npMembers } from \"../db/schema/community.js\";\nimport { npMedia, npMediaRefs } from \"../db/schema/media.js\";\nimport { npUsers } from \"../db/schema/system.js\";\nimport { enqueueJob } from \"../jobs/queue.js\";\nimport { getLogger } from \"../observability/logger.js\";\nimport { getDb } from \"../db/runtime.js\";\nimport {\n DEFAULT_IMAGE_SIZES,\n processImage,\n type NpProcessedImageResult,\n} from \"./processor.js\";\nimport type { NpStorageAdapter } from \"../storage/types.js\";\n\n/**\n * Trailing-window for member upload quotas (`perDay` in\n * `npMemberUploadQuota`). Default 24h matches the historical\n * \"daily quota\" semantics; override via\n * `NP_MEMBER_QUOTA_WINDOW_HOURS` to shift to weekly or hourly\n * caps without touching code.\n */\nconst MEMBER_QUOTA_WINDOW_MS =\n readEnvPositiveInt(\"NP_MEMBER_QUOTA_WINDOW_HOURS\", 24) * 60 * 60 * 1000;\n\ninterface SelectQuery extends Promise<unknown[]> {\n where(condition: ReturnType<typeof and> | ReturnType<typeof isNull>): SelectQuery;\n orderBy(order: ReturnType<typeof desc>): SelectQuery;\n limit(limit: number): SelectQuery;\n offset(offset: number): SelectQuery;\n}\n\ninterface InsertValuesQuery extends Promise<unknown> {\n returning(): Promise<unknown[]>;\n}\n\ninterface DrizzleDatabaseLike {\n insert(table: PgTable): {\n values(values: Record<string, unknown> | Record<string, unknown>[]): InsertValuesQuery;\n };\n update(table: PgTable): {\n set(values: Record<string, unknown>): {\n where(condition: ReturnType<typeof and> | ReturnType<typeof eq>): {\n returning(): Promise<unknown[]>;\n };\n };\n };\n delete(table: PgTable): {\n where(condition: ReturnType<typeof inArray> ): Promise<unknown>;\n };\n select(selection?: Record<string, unknown>): {\n from(table: PgTable): SelectQuery;\n };\n}\n\ninterface MediaRecord {\n id: string;\n filename: string;\n originalFilename: string;\n mimeType: string;\n filesize: number;\n width: number | null;\n height: number | null;\n sizes: Record<string, Record<string, unknown>> | null;\n storageKey: string;\n hash: string;\n status: \"processing\" | \"ready\" | \"error\";\n folderId: string | null;\n uploadedBy: string | null;\n createdAt: Date;\n updatedAt: Date;\n deletedAt: Date | null;\n}\n\nlet storageAdapter: NpStorageAdapter | null = null;\n\nexport function setStorageAdapter(adapter: NpStorageAdapter): void {\n storageAdapter = adapter;\n}\n\nexport function getStorageAdapter(): NpStorageAdapter {\n if (!storageAdapter) {\n throw new Error(\"Storage adapter not initialized. Call setStorageAdapter() first.\");\n }\n\n return storageAdapter;\n}\n\n/**\n * Polymorphic uploader: a row on `np_media` is owned by exactly\n * one of staff (`uploadedBy` → `np_users.id`) or member\n * (`uploadedByMemberId` → `np_members.id`, Phase 9.7j). Pass a\n * `null` value as the second argument to `uploadMedia` for plugin /\n * system uploads with no human owner — both columns stay null and\n * the audit log carries the actor.\n */\nexport type NpMediaUploader =\n | { kind: \"staff\"; userId: string }\n | { kind: \"member\"; memberId: string }\n | null;\n\nexport async function uploadMedia(\n file: { buffer: Buffer; originalFilename: string; mimeType: string },\n uploader: NpMediaUploader | string,\n folderId?: string,\n): Promise<{ id: string; status: string }> {\n // Backwards-compat: the original signature was\n // `uploadMedia(file, userId: string | null, folderId?)`. Existing\n // callers (plugin context, admin bulk uploads, etc.) pass a bare\n // string. Coerce that into the staff variant of the polymorphic\n // shape so the rest of this function only deals with the union.\n const resolvedUploader: NpMediaUploader =\n typeof uploader === \"string\"\n ? { kind: \"staff\", userId: uploader }\n : uploader;\n\n const id = randomUUID();\n const extension = resolveFileExtension(file.originalFilename, file.mimeType);\n const storageKey = `media/${id}/original.${extension}`;\n const now = new Date();\n const insertValues = {\n id,\n filename: file.originalFilename,\n originalFilename: file.originalFilename,\n mimeType: file.mimeType,\n filesize: file.buffer.byteLength,\n storageKey,\n hash: createHash(\"sha256\").update(file.buffer).digest(\"hex\"),\n status: \"processing\" as const,\n folderId,\n uploadedBy:\n resolvedUploader && resolvedUploader.kind === \"staff\"\n ? resolvedUploader.userId\n : null,\n uploadedByMemberId:\n resolvedUploader && resolvedUploader.kind === \"member\"\n ? resolvedUploader.memberId\n : null,\n createdAt: now,\n updatedAt: now,\n };\n\n // Phase 9.7p: per-member upload quota. Staff uploads are never\n // gated. Phase 9.7p-followup (#120) — the count + insert must be\n // atomic per member, otherwise concurrent uploads can both\n // observe the same pre-insert count and both succeed past the\n // cap. Wrap the gated branch in a transaction holding a Postgres\n // advisory lock keyed on the member id; cross-member uploaders\n // don't contend (different lock keys), same-member concurrent\n // uploaders serialize and the second one sees the updated\n // count.\n //\n // Storage upload happens AFTER the DB row commits so the quota\n // count is correct before bytes touch storage. If the upload\n // fails (#138 follow-up), we hard-delete the just-inserted row\n // so it stops counting against quota and doesn't strand the\n // member with a permanent ghost. We do NOT just mark the row\n // `error` here — there's no storage object to inspect, no\n // processor will arrive (the job hasn't been enqueued yet),\n // and the quota count filters by `deletedAt IS NULL`, not\n // `status`. Hard delete is the right semantic.\n if (resolvedUploader && resolvedUploader.kind === \"member\") {\n const memberId = resolvedUploader.memberId;\n const dbPg = getDb();\n await dbPg.transaction(async (tx) => {\n // `pg_advisory_xact_lock` auto-releases on commit/rollback.\n // `hashtextextended` produces a stable int8 from a UUID\n // string — collisions across different member ids are\n // benign (worst case some unrelated members serialize).\n await tx.execute(\n sql`SELECT pg_advisory_xact_lock(hashtextextended(${memberId}, 0))`,\n );\n await assertMemberUploadQuota(memberId, tx);\n await tx.insert(npMedia).values(insertValues);\n });\n } else {\n const db = getDb() as unknown as DrizzleDatabaseLike;\n await db.insert(npMedia).values(insertValues);\n }\n\n const adapter = getStorageAdapter();\n try {\n await adapter.upload(storageKey, file.buffer, {\n contentType: file.mimeType,\n contentLength: file.buffer.byteLength,\n originalFilename: file.originalFilename,\n });\n } catch (err) {\n // Storage failed after the DB row committed. Roll the row\n // back so it doesn't (a) eat the member's quota allowance\n // for nothing, (b) confuse operators with a permanent\n // `processing` row that never gets a job. Cleanup is\n // best-effort — if the delete itself fails we still surface\n // the original storage error to the caller, since that's\n // what they need to act on.\n try {\n const cleanupDb = getDb() as unknown as DrizzleDatabaseLike;\n await cleanupDb.delete(npMedia).where(eq(npMedia.id, id));\n } catch (cleanupErr) {\n // Swallow so the original storage error reaches the\n // caller — that's what they need to act on. But don't go\n // silent: a failed cleanup leaves a permanent ghost row\n // in `processing` that eats the member's quota with no\n // storage object to inspect and no job ever enqueued.\n // Operators need a signal to find and remediate it.\n getLogger().error(\"media upload cleanup failed\", {\n mediaId: id,\n storageKey,\n error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr),\n });\n }\n throw err;\n }\n\n await enqueueJob(\"media:processImage\", { mediaId: id });\n\n return { id, status: \"processing\" };\n}\n\n/**\n * Throws `NpRateLimitError` (429) if the member is at or over\n * their per-day or lifetime upload cap. Both bounds count\n * non-deleted rows, so admin / member deletes free up quota the\n * same way (mirrors the 9.7l purge semantic). When both bounds\n * are `null` (the default), this function is a no-op aside from\n * a single settings read.\n *\n * Defer-loaded `getCommunitySettings` to avoid an import cycle\n * with `community/settings.ts` — that module reads `getDb()`,\n * which is wired by the same bootstrap that wires the media DB,\n * so they sit on the same module layer; deferring keeps a clean\n * one-way edge from media → community for this single call site.\n */\nasync function assertMemberUploadQuota(\n memberId: string,\n txDb?: NodePgDatabase<Record<string, unknown>>,\n): Promise<void> {\n const { getCommunitySettings } = await import(\n \"../community/settings.js\"\n );\n const { NpRateLimitError } = await import(\"../errors.js\");\n const settings = await getCommunitySettings();\n const { perDay, total } = settings.memberUploadQuota;\n if (perDay === null && total === null) return;\n\n // When invoked inside the upload transaction (#120 fix), the\n // count + downstream insert run under the same advisory lock,\n // so the count must use the tx handle to see writes by sibling\n // statements. When called from elsewhere we fall back to the\n // shared media DB.\n const db =\n txDb ??\n (getDb());\n\n if (total !== null) {\n const [row] = (await db\n .select({ value: count() })\n .from(npMedia)\n .where(\n and(\n eq(npMedia.uploadedByMemberId, memberId),\n isNull(npMedia.deletedAt),\n ),\n )) as Array<{ value: number }>;\n const used = row?.value ?? 0;\n if (used >= total) {\n throw new NpRateLimitError(\n `Upload quota exceeded — this account has reached its lifetime cap of ${total} uploads.`,\n );\n }\n }\n\n if (perDay !== null) {\n const since = new Date(Date.now() - MEMBER_QUOTA_WINDOW_MS);\n const [row] = (await db\n .select({ value: count() })\n .from(npMedia)\n .where(\n and(\n eq(npMedia.uploadedByMemberId, memberId),\n isNull(npMedia.deletedAt),\n gte(npMedia.createdAt, since),\n ),\n )) as Array<{ value: number }>;\n const recent = row?.value ?? 0;\n if (recent >= perDay) {\n throw new NpRateLimitError(\n `Upload rate limit exceeded — try again later (max ${perDay} uploads per 24 hours).`,\n );\n }\n }\n}\n\nexport async function processMediaImage(\n mediaId: string,\n config: { sizes?: NpImageSize[]; format?: string; quality?: number },\n): Promise<void> {\n const db = getDb() as unknown as DrizzleDatabaseLike;\n const adapter = getStorageAdapter();\n const media = await getMediaRecordById(mediaId);\n\n if (!media) {\n throw new Error(`Media '${mediaId}' not found.`);\n }\n\n try {\n const originalStream = await adapter.getStream(media.storageKey);\n const originalBuffer = await consumeBuffer(Readable.fromWeb(originalStream));\n const processed = await processImage(\n originalBuffer,\n config.sizes ?? DEFAULT_IMAGE_SIZES,\n { format: config.format, quality: config.quality },\n );\n const format = config.format ?? \"webp\";\n const mimeType = getFormatMimeType(format);\n const sizes = await uploadImageVariants(adapter, media.id, processed, format, mimeType);\n\n await db\n .update(npMedia)\n .set({\n sizes,\n width: processed.source.width,\n height: processed.source.height,\n status: \"ready\",\n updatedAt: new Date(),\n })\n .where(eq(npMedia.id, media.id))\n .returning();\n } catch (error) {\n await db\n .update(npMedia)\n .set({\n status: \"error\",\n updatedAt: new Date(),\n })\n .where(eq(npMedia.id, media.id))\n .returning();\n\n throw error;\n }\n}\n\nexport async function getMediaById(id: string): Promise<Record<string, unknown> | null> {\n const db = getDb() as unknown as DrizzleDatabaseLike;\n const [media] = await db\n .select()\n .from(npMedia)\n .where(and(eq(npMedia.id, id), isNull(npMedia.deletedAt)))\n .limit(1);\n\n return media ? toRecord(media) : null;\n}\n\nexport async function deleteMedia(\n id: string,\n): Promise<{ deleted: boolean; references?: unknown[] }> {\n const db = getDb() as unknown as DrizzleDatabaseLike;\n const references = await db.select().from(npMediaRefs).where(eq(npMediaRefs.mediaId, id));\n\n if (references.length > 0) {\n return { deleted: false, references };\n }\n\n const [media] = await db\n .select()\n .from(npMedia)\n .where(and(eq(npMedia.id, id), isNull(npMedia.deletedAt)))\n .limit(1);\n\n if (!media) {\n return { deleted: false };\n }\n\n await db\n .update(npMedia)\n .set({\n deletedAt: new Date(),\n updatedAt: new Date(),\n })\n .where(eq(npMedia.id, id))\n .returning();\n\n return { deleted: true };\n}\n\n/**\n * Phase 9.7k uploader filters. `uploaderKind` partitions the\n * library into staff-uploaded rows (`uploaded_by IS NOT NULL`) vs\n * member-uploaded rows (`uploaded_by_member_id IS NOT NULL`) — the\n * two columns are mutually exclusive on every row written through\n * `uploadMedia`. `uploadedByMemberId` narrows to a specific member\n * for \"show me everything @handle uploaded\" investigations after a\n * spam wave.\n */\nexport type NpMediaUploaderKindFilter = \"staff\" | \"member\";\n\nexport async function listMedia(options: {\n page?: number;\n limit?: number;\n folderId?: string;\n mimeType?: string;\n uploaderKind?: NpMediaUploaderKindFilter;\n uploadedByMemberId?: string;\n /**\n * Substring match against `filename` and `alt`. Matches\n * server-side via `ILIKE`, so the page-builder block-image\n * picker can search the whole library without paging through\n * every result client-side. Empty / whitespace-only `q` is\n * treated as no filter.\n */\n q?: string;\n}): Promise<NpFindResult> {\n const db = getDb() as unknown as DrizzleDatabaseLike;\n const page = normalizePage(options.page);\n const limit = normalizeLimit(options.limit);\n const offset = (page - 1) * limit;\n const conditions = [isNull(npMedia.deletedAt)];\n\n if (options.folderId) {\n conditions.push(eq(npMedia.folderId, options.folderId));\n }\n\n if (options.mimeType) {\n conditions.push(eq(npMedia.mimeType, options.mimeType));\n }\n\n if (options.uploaderKind === \"staff\") {\n conditions.push(isNotNull(npMedia.uploadedBy));\n } else if (options.uploaderKind === \"member\") {\n conditions.push(isNotNull(npMedia.uploadedByMemberId));\n }\n\n if (options.uploadedByMemberId) {\n conditions.push(eq(npMedia.uploadedByMemberId, options.uploadedByMemberId));\n }\n\n // Substring search across filename + alt. We match `ILIKE\n // %q%` against both columns and OR them so the picker's\n // search box hits filenames the operator remembers and alt\n // text they wrote. SQL escapes the literal `%` / `_` chars\n // by doubling them so a filename containing them isn't\n // treated as a wildcard.\n if (options.q && options.q.trim().length > 0) {\n const needle = `%${options.q.trim().replace(/[%_]/g, (c) => `\\\\${c}`)}%`;\n const search = or(\n ilike(npMedia.filename, needle),\n ilike(npMedia.alt, needle),\n );\n if (search) conditions.push(search);\n }\n\n const whereClause = combineConditions(conditions);\n // The local `DrizzleDatabaseLike` interface in this file is\n // narrow on purpose (only `select/insert/update/delete`); a\n // proper leftJoin chain would require typing the full Drizzle\n // builder pipeline. Cast through `unknown` for this query —\n // safer than widening the interface and dragging join semantics\n // into every other media call site.\n const joined = (db as unknown as {\n select: (s: Record<string, unknown>) => {\n from: (t: PgTable) => {\n leftJoin: (j: PgTable, c: unknown) => {\n leftJoin: (j: PgTable, c: unknown) => {\n where: (c: unknown) => {\n orderBy: (o: unknown) => {\n limit: (n: number) => {\n offset: (n: number) => Promise<Array<Record<string, unknown>>>;\n };\n };\n };\n };\n };\n };\n };\n })\n .select({\n media: npMedia,\n userName: npUsers.name,\n userEmail: npUsers.email,\n memberHandle: npMembers.handle,\n memberDisplayName: npMembers.displayName,\n })\n .from(npMedia)\n .leftJoin(npUsers, eq(npMedia.uploadedBy, npUsers.id))\n .leftJoin(npMembers, eq(npMedia.uploadedByMemberId, npMembers.id))\n .where(whereClause)\n .orderBy(desc(npMedia.createdAt))\n .limit(limit)\n .offset(offset);\n\n const rows = (await joined) as Array<{\n media: Record<string, unknown>;\n userName: string | null;\n userEmail: string | null;\n memberHandle: string | null;\n memberDisplayName: string | null;\n }>;\n const [{ total }] = (whereClause\n ? await db.select({ total: count() }).from(npMedia).where(whereClause)\n : await db.select({ total: count() }).from(npMedia)) as Array<{ total: number | string }>;\n const totalDocs = Number(total ?? 0);\n const totalPages = totalDocs === 0 ? 0 : Math.ceil(totalDocs / limit);\n\n // Flatten the JOIN result so each doc carries an `uploader`\n // sub-object alongside the standard media columns. Keeps the\n // shape backwards-compatible (the existing media columns are\n // still at the top level).\n const docs = rows.map((row) => ({\n ...row.media,\n uploader: row.userName !== null\n ? {\n kind: \"staff\" as const,\n name: row.userName,\n email: row.userEmail,\n }\n : row.memberHandle !== null\n ? {\n kind: \"member\" as const,\n handle: row.memberHandle,\n displayName: row.memberDisplayName,\n }\n : null,\n }));\n\n return {\n docs: docs,\n totalDocs,\n totalPages,\n page,\n limit,\n hasNextPage: page < totalPages,\n hasPrevPage: page > 1 && totalDocs > 0,\n };\n}\n\nexport async function cleanupDeletedMedia(olderThanDays: number): Promise<number> {\n const db = getDb() as unknown as DrizzleDatabaseLike;\n const adapter = getStorageAdapter();\n const threshold = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000);\n const rows = await db\n .select()\n .from(npMedia)\n .where(and(isNotNull(npMedia.deletedAt), lt(npMedia.deletedAt, threshold)));\n const mediaRows = rows.map(toMediaRecord);\n\n if (mediaRows.length === 0) {\n return 0;\n }\n\n for (const media of mediaRows) {\n const keys = new Set<string>([\n media.storageKey,\n ...extractVariantStorageKeys(media.sizes),\n ]);\n\n for (const key of keys) {\n try {\n await adapter.delete(key);\n } catch {\n continue;\n }\n }\n }\n\n await db.delete(npMedia).where(inArray(npMedia.id, mediaRows.map((media) => media.id)));\n\n return mediaRows.length;\n}\n\nasync function getMediaRecordById(id: string): Promise<MediaRecord | null> {\n const db = getDb() as unknown as DrizzleDatabaseLike;\n const [media] = await db\n .select()\n .from(npMedia)\n .where(and(eq(npMedia.id, id), isNull(npMedia.deletedAt)))\n .limit(1);\n\n return media ? toMediaRecord(media) : null;\n}\n\nasync function uploadImageVariants(\n adapter: NpStorageAdapter,\n mediaId: string,\n processed: NpProcessedImageResult,\n format: string,\n mimeType: string,\n): Promise<Record<string, Record<string, unknown>>> {\n const entries = await Promise.all(\n processed.variants.map(async (variant) => {\n const filename = `${variant.name}.${format}`;\n const storageKey = `media/${mediaId}/${filename}`;\n\n await adapter.upload(storageKey, variant.buffer, {\n contentType: mimeType,\n contentLength: variant.size,\n originalFilename: filename,\n });\n\n return [\n variant.name,\n {\n filename,\n mimeType,\n filesize: variant.size,\n width: variant.width,\n height: variant.height,\n storageKey,\n url: await adapter.getUrl(storageKey),\n },\n ] as const;\n }),\n );\n\n return Object.fromEntries(entries);\n}\n\nfunction extractVariantStorageKeys(\n sizes: Record<string, Record<string, unknown>> | null,\n): string[] {\n if (!sizes) {\n return [];\n }\n\n return Object.values(sizes)\n .map((size) => size.storageKey)\n .filter((value): value is string => typeof value === \"string\" && value.length > 0);\n}\n\nfunction resolveFileExtension(originalFilename: string, mimeType: string): string {\n const extension = extname(originalFilename).slice(1).toLowerCase();\n\n if (extension) {\n return extension;\n }\n\n switch (mimeType) {\n case \"image/jpeg\":\n return \"jpg\";\n case \"image/png\":\n return \"png\";\n case \"image/webp\":\n return \"webp\";\n case \"image/avif\":\n return \"avif\";\n case \"image/gif\":\n return \"gif\";\n case \"application/pdf\":\n return \"pdf\";\n default:\n return \"bin\";\n }\n}\n\nfunction getFormatMimeType(format: string): string {\n switch (format) {\n case \"avif\":\n return \"image/avif\";\n case \"jpeg\":\n return \"image/jpeg\";\n case \"png\":\n return \"image/png\";\n case \"webp\":\n default:\n return \"image/webp\";\n }\n}\n\nfunction combineConditions(\n conditions: Array<ReturnType<typeof and> | ReturnType<typeof isNull> >,\n): ReturnType<typeof and> | ReturnType<typeof isNull> | undefined {\n if (conditions.length === 0) {\n return undefined;\n }\n\n if (conditions.length === 1) {\n return conditions[0];\n }\n\n return and(...conditions);\n}\n\nfunction normalizePage(page?: number): number {\n if (!page || page < 1) {\n return 1;\n }\n\n return Math.floor(page);\n}\n\nfunction normalizeLimit(limit?: number): number {\n if (!limit || limit < 1) {\n return 10;\n }\n\n return Math.floor(limit);\n}\n\nfunction toMediaRecord(value: unknown): MediaRecord {\n const record = toRecord(value);\n\n return {\n id: asString(record.id, \"id\"),\n filename: asString(record.filename, \"filename\"),\n originalFilename: asString(record.originalFilename, \"originalFilename\"),\n mimeType: asString(record.mimeType, \"mimeType\"),\n filesize: asNumber(record.filesize, \"filesize\"),\n width: asNullableNumber(record.width),\n height: asNullableNumber(record.height),\n sizes: asSizes(record.sizes),\n storageKey: asString(record.storageKey, \"storageKey\"),\n hash: asString(record.hash, \"hash\"),\n status: asMediaStatus(record.status),\n folderId: asNullableString(record.folderId),\n uploadedBy: asNullableString(record.uploadedBy),\n createdAt: asDate(record.createdAt, \"createdAt\"),\n updatedAt: asDate(record.updatedAt, \"updatedAt\"),\n deletedAt: asNullableDate(record.deletedAt),\n };\n}\n\nfunction asSizes(value: unknown): Record<string, Record<string, unknown>> | null {\n if (value == null) {\n return null;\n }\n\n const record = toRecord(value);\n const sizes: Record<string, Record<string, unknown>> = {};\n\n for (const [key, entry] of Object.entries(record)) {\n const sizeRecord = toRecord(entry);\n sizes[key] = sizeRecord;\n }\n\n return sizes;\n}\n\nfunction asMediaStatus(value: unknown): MediaRecord[\"status\"] {\n if (value === \"processing\" || value === \"ready\" || value === \"error\") {\n return value;\n }\n\n throw new Error(\"Invalid media status.\");\n}\n\nfunction asString(value: unknown, field: string): string {\n if (typeof value !== \"string\" || value.length === 0) {\n throw new Error(`Invalid ${field}.`);\n }\n\n return value;\n}\n\nfunction asNullableString(value: unknown): string | null {\n if (value == null) {\n return null;\n }\n\n return asString(value, \"string field\");\n}\n\nfunction asNumber(value: unknown, field: string): number {\n if (typeof value !== \"number\") {\n throw new Error(`Invalid ${field}.`);\n }\n\n return value;\n}\n\nfunction asNullableNumber(value: unknown): number | null {\n if (value == null) {\n return null;\n }\n\n return asNumber(value, \"number field\");\n}\n\nfunction asDate(value: unknown, field: string): Date {\n if (!(value instanceof Date)) {\n throw new Error(`Invalid ${field}.`);\n }\n\n return value;\n}\n\nfunction asNullableDate(value: unknown): Date | null {\n if (value == null) {\n return null;\n }\n\n return asDate(value, \"date field\");\n}\n\nfunction toRecord(value: unknown): Record<string, unknown> {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) {\n throw new Error(\"Expected object record.\");\n }\n\n return value as Record<string, unknown>;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
getStorageAdapter
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-EAYUAXW3.js";
|
|
4
4
|
import {
|
|
5
5
|
getDb
|
|
6
6
|
} from "./chunk-XANPEOJC.js";
|
|
@@ -38,4 +38,4 @@ async function getMediaUrl(id, options = {}) {
|
|
|
38
38
|
export {
|
|
39
39
|
getMediaUrl
|
|
40
40
|
};
|
|
41
|
-
//# sourceMappingURL=chunk-
|
|
41
|
+
//# sourceMappingURL=chunk-EWVXP3GP.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/community/notification-prefs.ts"],"sourcesContent":["import { eq } from \"drizzle-orm\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npMembers } from \"../db/schema/community.js\";\nimport { NpNotFoundError, NpValidationError } from \"../errors.js\";\n\n/**\n * Phase 16.3 — per-member notification preferences.\n *\n * The persisted shape is a JSONB blob on `np_members.notification_prefs`\n * so adding fields (digest cadence in 16.4, channel toggles later)\n * stays a typescript-only change. Today we honor:\n *\n * - `disabled: string[]` — kinds the member opted out of. The\n * `createNotification` gate consults this and silently drops\n * the row. Default empty (= every kind enabled).\n *\n * The vocabulary of `kinds` is defined here so the UI has a single\n * source of truth — settings page renders a toggle for each entry,\n * and the API only accepts kinds that appear in the list (so a\n * forged client can't disable arbitrary strings to bloat the JSONB).\n */\n\nexport interface NpNotificationKindMeta {\n kind: string;\n /** Short human label. */\n label: string;\n /** Description rendered next to the toggle. */\n description: string;\n}\n\n/**\n * Closed vocabulary of toggle-able kinds. New notification kinds\n * land here when they ship; plugins that want their own\n * preferences register entries via `registerNotificationKind`.\n */\nconst builtinKinds: NpNotificationKindMeta[] = [\n {\n kind: \"comment.reply\",\n label: \"Replies\",\n description: \"Someone replied to one of your comments.\",\n },\n {\n kind: \"comment.mention\",\n label: \"Mentions in comments\",\n description: \"Someone @-mentioned you in a comment.\",\n },\n {\n kind: \"document.mention\",\n label: \"Mentions in discussions\",\n description: \"Someone @-mentioned you in a discussion / thread.\",\n },\n {\n kind: \"reaction.received\",\n label: \"Reactions\",\n description: \"Someone reacted to your comment or document.\",\n },\n {\n kind: \"follow.received\",\n label: \"New followers\",\n description: \"Someone started following you.\",\n },\n];\n\nconst dynamicKinds: NpNotificationKindMeta[] = [];\n\n/** Plugin-extensible registration. Idempotent on `kind`. */\nexport function registerNotificationKind(meta: NpNotificationKindMeta): void {\n if (builtinKinds.some((k) => k.kind === meta.kind)) return;\n const idx = dynamicKinds.findIndex((k) => k.kind === meta.kind);\n if (idx >= 0) {\n dynamicKinds[idx] = meta;\n } else {\n dynamicKinds.push(meta);\n }\n}\n\n/** Returns the union of builtin + plugin-registered kinds. */\nexport function listNotificationKinds(): NpNotificationKindMeta[] {\n return [...builtinKinds, ...dynamicKinds];\n}\n\nexport type NpDigestCadence = \"off\" | \"daily\" | \"weekly\";\n\nconst DIGEST_CADENCES: readonly NpDigestCadence[] = [\"off\", \"daily\", \"weekly\"] as const;\n\nexport interface NpNotificationPrefs {\n /** Kinds the member opted out of. Empty / missing = all kinds enabled. */\n disabled: string[];\n /**\n * Phase 16.4 — email digest cadence. `off` (default) disables\n * the digest. `daily` and `weekly` opt the member into a\n * batched email of unread notifications, scheduled by the\n * `notifications:sendDigest` recurring job.\n */\n digest: NpDigestCadence;\n /**\n * Set when the digest sweep last sent an email to this member.\n * Used to scope each digest to \"unread since the last send\" so\n * members aren't repeatedly emailed about the same row. Stored\n * as ISO-8601 string in the JSONB blob; `null` for accounts\n * that have never received a digest.\n *\n * Issue #218 — superseded by `lastDigestAtBySite` once a member\n * receives a digest under the per-site fan-out path. The legacy\n * field is preserved for forward-compat reads (single-site\n * deploys still see + write it via the fallback chain) and as\n * a \"any digest, ever?\" marker for analytics.\n */\n lastDigestAt: string | null;\n /**\n * Issue #218 — per-(site, cadence) timestamp map. Replaces the\n * single `lastDigestAt` for multi-site deployments. Empty when\n * the member has never received a digest under the site-scoped\n * sweep.\n */\n lastDigestAtBySite: Record<string, Partial<Record<NpDigestCadence, string>>>;\n}\n\nconst EMPTY_PREFS: NpNotificationPrefs = {\n disabled: [],\n digest: \"off\",\n lastDigestAt: null,\n lastDigestAtBySite: {},\n};\n\nfunction normalizeDigest(raw: unknown): NpDigestCadence {\n return DIGEST_CADENCES.includes(raw as NpDigestCadence) ? (raw as NpDigestCadence) : \"off\";\n}\n\nfunction normalizeLastDigestAt(raw: unknown): string | null {\n return typeof raw === \"string\" && raw.length > 0 ? raw : null;\n}\n\nfunction normalizeLastDigestBySite(\n raw: unknown,\n): Record<string, Partial<Record<NpDigestCadence, string>>> {\n if (!raw || typeof raw !== \"object\" || Array.isArray(raw)) return {};\n const out: Record<string, Partial<Record<NpDigestCadence, string>>> = {};\n for (const [siteId, value] of Object.entries(raw as Record<string, unknown>)) {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) continue;\n const inner: Partial<Record<NpDigestCadence, string>> = {};\n for (const [cadence, ts] of Object.entries(value as Record<string, unknown>)) {\n if (!DIGEST_CADENCES.includes(cadence as NpDigestCadence)) continue;\n if (typeof ts === \"string\" && ts.length > 0) {\n inner[cadence as NpDigestCadence] = ts;\n }\n }\n if (Object.keys(inner).length > 0) out[siteId] = inner;\n }\n return out;\n}\n\nfunction normalizePrefs(raw: unknown): NpNotificationPrefs {\n if (!raw || typeof raw !== \"object\") return { ...EMPTY_PREFS, lastDigestAtBySite: {} };\n const obj = raw as Record<string, unknown>;\n const disabled = Array.isArray(obj.disabled)\n ? obj.disabled.filter((k): k is string => typeof k === \"string\")\n : [];\n return {\n disabled,\n digest: normalizeDigest(obj.digest),\n lastDigestAt: normalizeLastDigestAt(obj.lastDigestAt),\n lastDigestAtBySite: normalizeLastDigestBySite(obj.lastDigestAtBySite),\n };\n}\n\nexport async function getMemberNotificationPrefs(memberId: string): Promise<NpNotificationPrefs> {\n const db = getDb();\n const [row] = (await db\n .select({ prefs: npMembers.notificationPrefs })\n .from(npMembers)\n .where(eq(npMembers.id, memberId))\n .limit(1)) as Array<{ prefs: Record<string, unknown> }>;\n if (!row) throw new NpNotFoundError(\"member\", memberId);\n return normalizePrefs(row.prefs);\n}\n\nexport interface SetMemberNotificationPrefsInput {\n memberId: string;\n /**\n * Replacement deny-list. Only kinds listed in\n * `listNotificationKinds()` are accepted; unknown strings\n * raise NpValidationError so a forged client can't bloat the\n * JSONB or hide future framework kinds via a stale list.\n * Optional — when omitted the existing list is preserved.\n */\n disabled?: string[];\n /**\n * Phase 16.4 — email digest cadence. Optional; when omitted\n * the existing setting is preserved. `off` clears the\n * member's enrollment.\n */\n digest?: NpDigestCadence;\n}\n\nexport async function setMemberNotificationPrefs(\n input: SetMemberNotificationPrefsInput,\n): Promise<NpNotificationPrefs> {\n const known = new Set(listNotificationKinds().map((k) => k.kind));\n let cleanedDisabled: string[] | undefined;\n if (input.disabled !== undefined) {\n cleanedDisabled = [];\n const seen = new Set<string>();\n for (const raw of input.disabled) {\n if (typeof raw !== \"string\") {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"disabled\", message: \"Each entry must be a string\" },\n ]);\n }\n if (!known.has(raw)) {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"disabled\", message: `Unknown notification kind: ${raw}` },\n ]);\n }\n if (seen.has(raw)) continue;\n seen.add(raw);\n cleanedDisabled.push(raw);\n }\n }\n if (input.digest !== undefined && !DIGEST_CADENCES.includes(input.digest)) {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"digest\",\n message: `digest must be one of: ${DIGEST_CADENCES.join(\", \")}`,\n },\n ]);\n }\n const db = getDb();\n\n // Read-then-merge so we don't clobber other JSONB keys\n // (lastDigestAt, future channel toggles, etc.).\n const [existing] = (await db\n .select({ prefs: npMembers.notificationPrefs })\n .from(npMembers)\n .where(eq(npMembers.id, input.memberId))\n .limit(1)) as Array<{ prefs: Record<string, unknown> }>;\n if (!existing) throw new NpNotFoundError(\"member\", input.memberId);\n\n const merged: Record<string, unknown> = { ...(existing.prefs ?? {}) };\n if (cleanedDisabled !== undefined) merged.disabled = cleanedDisabled;\n if (input.digest !== undefined) merged.digest = input.digest;\n\n await db\n .update(npMembers)\n .set({ notificationPrefs: merged, updatedAt: new Date() })\n .where(eq(npMembers.id, input.memberId));\n\n return normalizePrefs(merged);\n}\n\n/**\n * Phase 16.4 — bookkeeping helper called by the digest sweep\n * after a successful email send. Stamps `lastDigestAt` so the\n * next run scopes its query to the correct window. Read-merge\n * to preserve other JSONB keys.\n *\n * Issue #218 — when a `siteId` + `cadence` pair is supplied,\n * the per-site / per-cadence map is updated so the next sweep\n * for that tenant scopes to the correct \"since\" window. The\n * legacy single `lastDigestAt` field is also stamped for\n * forward-compat with single-site deploys (and as a \"received\n * any digest, ever?\" marker for analytics).\n */\nexport async function recordDigestSent(\n memberId: string,\n sentAt: Date,\n scope?: { siteId: string; cadence: NpDigestCadence },\n): Promise<void> {\n const db = getDb();\n const [existing] = (await db\n .select({ prefs: npMembers.notificationPrefs })\n .from(npMembers)\n .where(eq(npMembers.id, memberId))\n .limit(1)) as Array<{ prefs: Record<string, unknown> }>;\n if (!existing) return;\n const prior = existing.prefs ?? {};\n const merged: Record<string, unknown> = {\n ...prior,\n lastDigestAt: sentAt.toISOString(),\n };\n if (scope) {\n const priorBySite = normalizeLastDigestBySite(\n (prior as { lastDigestAtBySite?: unknown }).lastDigestAtBySite,\n );\n const siteSlot = { ...(priorBySite[scope.siteId] ?? {}) };\n siteSlot[scope.cadence] = sentAt.toISOString();\n merged.lastDigestAtBySite = { ...priorBySite, [scope.siteId]: siteSlot };\n }\n await db\n .update(npMembers)\n .set({ notificationPrefs: merged, updatedAt: new Date() })\n .where(eq(npMembers.id, memberId));\n}\n\n/**\n * Inbox-side gate consulted by `createNotification`. Returns\n * `false` when the recipient explicitly opted out of `kind`.\n * Errors fail-open (return `true`) so a transient DB blip\n * doesn't silently swallow notifications.\n */\nexport async function isNotificationKindEnabled(memberId: string, kind: string): Promise<boolean> {\n try {\n const prefs = await getMemberNotificationPrefs(memberId);\n return !prefs.disabled.includes(kind);\n } catch {\n return true;\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
recordDigestSent
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-I4FSVEJK.js";
|
|
4
4
|
import {
|
|
5
5
|
NP_DEFAULT_SITE_ID,
|
|
6
6
|
listSites
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from "./chunk-LSHHRDVR.js";
|
|
11
11
|
import {
|
|
12
12
|
getLogger
|
|
13
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-Q7MK5ZKG.js";
|
|
14
14
|
import {
|
|
15
15
|
getDb
|
|
16
16
|
} from "./chunk-XANPEOJC.js";
|
|
@@ -191,4 +191,4 @@ export {
|
|
|
191
191
|
buildDigestEmail,
|
|
192
192
|
runDigestSweep
|
|
193
193
|
};
|
|
194
|
-
//# sourceMappingURL=chunk-
|
|
194
|
+
//# sourceMappingURL=chunk-K4CJ3KXB.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/community/digest.ts"],"sourcesContent":["import { and, desc, eq, gt, isNull, sql } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npMembers, npNotifications } from \"../db/schema/community.js\";\nimport { getEmailAdapter } from \"../email/service.js\";\nimport { getLogger } from \"../observability/logger.js\";\nimport { listSites, NP_DEFAULT_SITE_ID } from \"../sites/registry.js\";\n\nimport { type NpDigestCadence, recordDigestSent } from \"./notification-prefs.js\";\n\n/**\n * Phase 16.4 — email digest fan-out. The `notifications:sendDigest`\n * recurring job calls `runDigestSweep(cadence)` on a daily and a\n * weekly schedule; the function fetches every active member who\n * opted into that cadence, builds an inbox summary scoped to \"since\n * last digest\" (falling back to the cadence window when the member\n * has never received one), renders an email through the configured\n * `NpEmailAdapter`, and stamps `lastDigestAt` on success.\n *\n * The job is idempotent enough for production use: a sweep that\n * runs twice for the same window won't re-email members because\n * `lastDigestAt` advances on the first send. Failures inside the\n * loop are logged-and-continued — one stuck member doesn't block\n * the rest of the sweep.\n */\n\nexport interface NpDigestNotificationSummary {\n id: string;\n kind: string;\n payload: Record<string, unknown>;\n createdAt: Date;\n}\n\nexport interface NpDigestEmailContent {\n subject: string;\n text: string;\n html: string;\n}\n\nexport interface BuildDigestEmailInput {\n member: { displayName: string; handle: string };\n notifications: NpDigestNotificationSummary[];\n cadence: NpDigestCadence;\n /** Site display name; defaults to \"your site\" so the noop adapter is still readable. */\n siteName?: string;\n}\n\nconst LABELS: Record<string, string> = {\n \"comment.reply\": \"New reply on your comment\",\n \"comment.mention\": \"You were mentioned in a comment\",\n \"document.mention\": \"You were mentioned in a discussion\",\n \"reaction.received\": \"Someone reacted to your content\",\n \"follow.received\": \"Someone followed you\",\n};\n\nfunction labelFor(kind: string): string {\n return LABELS[kind] ?? `Notification (${kind})`;\n}\n\nfunction escapeHtml(value: string): string {\n return value\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\");\n}\n\n/**\n * Pure renderer; exposed so plugins / tests can call it without\n * the DB read path.\n */\nexport function buildDigestEmail(input: BuildDigestEmailInput): NpDigestEmailContent {\n const site = input.siteName ?? \"your site\";\n const cadenceWord = input.cadence === \"weekly\" ? \"weekly\" : \"daily\";\n const total = input.notifications.length;\n const subject =\n total === 1\n ? `Your ${cadenceWord} digest from ${site}: 1 notification`\n : `Your ${cadenceWord} digest from ${site}: ${total} notifications`;\n\n const lines = input.notifications.map((n) => {\n const label = labelFor(n.kind);\n const when = n.createdAt.toISOString();\n return `- ${label} (${when})`;\n });\n const text = [\n `Hi @${input.member.handle},`,\n \"\",\n `You have ${total} unread notification${total === 1 ? \"\" : \"s\"} from the last ${cadenceWord} window:`,\n \"\",\n ...lines,\n \"\",\n `Manage your digest settings: /members/me/notifications`,\n ].join(\"\\n\");\n\n const items = input.notifications\n .map((n) => {\n const label = escapeHtml(labelFor(n.kind));\n const when = escapeHtml(n.createdAt.toISOString());\n return `<li><strong>${label}</strong> <span style=\"color:#64748b\">— ${when}</span></li>`;\n })\n .join(\"\");\n const html = [\n `<p>Hi @${escapeHtml(input.member.handle)},</p>`,\n `<p>You have ${total} unread notification${total === 1 ? \"\" : \"s\"} from the last ${cadenceWord} window:</p>`,\n `<ul>${items}</ul>`,\n `<p style=\"color:#64748b;font-size:0.9rem\">`,\n `Manage your digest settings at `,\n `<a href=\"/members/me/notifications\">/members/me/notifications</a>.`,\n `</p>`,\n ].join(\"\");\n\n return { subject, text, html };\n}\n\ninterface MemberDigestRow {\n id: string;\n email: string;\n handle: string;\n displayName: string;\n prefs: Record<string, unknown>;\n}\n\nfunction fallbackWindow(cadence: NpDigestCadence, now: Date): Date {\n const ms = cadence === \"weekly\" ? 7 * 24 * 60 * 60 * 1000 : 24 * 60 * 60 * 1000;\n return new Date(now.getTime() - ms);\n}\n\n/**\n * Pulls every active member whose `notification_prefs.digest`\n * matches `cadence`. The JSONB filter uses Postgres `->>`\n * extraction; the `digest` field is a small string, indexes are\n * unnecessary at v1 scale.\n */\nasync function listMembersForCadence(\n db: NodePgDatabase<Record<string, unknown>>,\n cadence: Exclude<NpDigestCadence, \"off\">,\n): Promise<MemberDigestRow[]> {\n const rows = (await db\n .select({\n id: npMembers.id,\n email: npMembers.email,\n handle: npMembers.handle,\n displayName: npMembers.displayName,\n prefs: npMembers.notificationPrefs,\n status: npMembers.status,\n })\n .from(npMembers)\n .where(\n and(\n eq(npMembers.status, \"active\"),\n sql`${npMembers.notificationPrefs} ->> 'digest' = ${cadence}`,\n ),\n )) as Array<MemberDigestRow & { status: string }>;\n return rows.map((r) => ({\n id: r.id,\n email: r.email,\n handle: r.handle,\n displayName: r.displayName,\n prefs: r.prefs,\n }));\n}\n\nasync function fetchUnreadSince(\n db: NodePgDatabase<Record<string, unknown>>,\n memberId: string,\n siteId: string,\n since: Date,\n): Promise<NpDigestNotificationSummary[]> {\n const rows = (await db\n .select({\n id: npNotifications.id,\n kind: npNotifications.kind,\n payload: npNotifications.payload,\n createdAt: npNotifications.createdAt,\n })\n .from(npNotifications)\n .where(\n and(\n eq(npNotifications.memberId, memberId),\n // Issue #218 — scope to the site we're sweeping. Without\n // this the digest mixed inboxes across tenants and the\n // recipient saw notifications from sites they don't even\n // know exist.\n eq(npNotifications.siteId, siteId),\n // Unread + within the window. If the member already read\n // everything in the inbox the digest would be noise, so we\n // skip silently (caller increments `skipped` when the list\n // comes back empty).\n gt(npNotifications.createdAt, since),\n isNull(npNotifications.readAt),\n ),\n )\n .orderBy(desc(npNotifications.createdAt))\n .limit(50)) as NpDigestNotificationSummary[];\n return rows;\n}\n\nexport interface RunDigestSweepInput {\n cadence: \"daily\" | \"weekly\";\n /** Defaults to `new Date()`. Tests override for determinism. */\n now?: Date;\n /** Site name woven into subject + body. Defaults to `\"your site\"`. */\n siteName?: string;\n}\n\nexport interface RunDigestSweepResult {\n considered: number;\n sent: number;\n skipped: number;\n failed: number;\n}\n\nexport async function runDigestSweep(input: RunDigestSweepInput): Promise<RunDigestSweepResult> {\n const now = input.now ?? new Date();\n const db = getDb();\n const adapter = getEmailAdapter();\n const log = getLogger();\n\n // Issue #218 — fan-out per site. The previous implementation\n // ran a single sweep that mixed every tenant's inbox into one\n // digest and stamped one global `lastDigestAt`; advancing it\n // for tenant A would suppress tenant B's next digest entirely.\n // We now iterate the site registry and run an independent\n // sweep per (site, member) — same email cadence, but each\n // recipient gets one email per site they have unread\n // notifications on.\n const sites = await listSites();\n const candidateSites = sites.length > 0 ? sites : [{ id: NP_DEFAULT_SITE_ID, name: \"\" }];\n const members = await listMembersForCadence(db, input.cadence);\n\n let considered = 0;\n let sent = 0;\n let skipped = 0;\n let failed = 0;\n\n for (const site of candidateSites) {\n for (const member of members) {\n considered += 1;\n const since = lastDigestSinceFor(member, site.id, input.cadence, now);\n\n const notifications = await fetchUnreadSince(db, member.id, site.id, since);\n if (notifications.length === 0) {\n skipped += 1;\n continue;\n }\n\n const email = buildDigestEmail({\n member: { displayName: member.displayName, handle: member.handle },\n notifications,\n cadence: input.cadence,\n // Caller-supplied `siteName` is an explicit override\n // (single-tenant deploys, tests pinning a friendly\n // brand name); the per-site `name` is the natural\n // multi-tenant default.\n siteName:\n input.siteName && input.siteName.length > 0\n ? input.siteName\n : typeof site.name === \"string\" && site.name.length > 0\n ? site.name\n : undefined,\n });\n\n try {\n await adapter.send({\n to: member.email,\n subject: email.subject,\n text: email.text,\n html: email.html,\n });\n await recordDigestSent(member.id, now, { siteId: site.id, cadence: input.cadence });\n sent += 1;\n } catch (err) {\n failed += 1;\n log.warn(\"digest send failed\", {\n memberId: member.id,\n siteId: site.id,\n cadence: input.cadence,\n error: err instanceof Error ? err.message : String(err),\n });\n }\n }\n }\n\n return { considered, sent, skipped, failed };\n}\n\n/**\n * Issue #218 — pick the right \"since\" cutoff for one (site,\n * member, cadence) sweep. Reads precedence:\n * 1. `lastDigestAtBySite[siteId][cadence]` — the per-site\n * timestamp the new sweep writes after each successful send.\n * 2. legacy `lastDigestAt` — single-tenant deploys without\n * site-scoped writes still keep their existing window.\n * 3. fallback window (24h / 7d) — a member who has never\n * received any digest.\n */\nfunction lastDigestSinceFor(\n member: MemberDigestRow,\n siteId: string,\n cadence: NpDigestCadence,\n now: Date,\n): Date {\n const prefs = (member.prefs ?? {});\n const bySite = prefs.lastDigestAtBySite as\n | Record<string, Partial<Record<string, string>>>\n | undefined;\n const perSite = bySite?.[siteId]?.[cadence];\n if (typeof perSite === \"string\") {\n const parsed = new Date(perSite);\n if (Number.isFinite(parsed.getTime())) return parsed;\n }\n if (typeof prefs.lastDigestAt === \"string\") {\n const parsed = new Date(prefs.lastDigestAt);\n if (Number.isFinite(parsed.getTime())) return parsed;\n }\n return fallbackWindow(cadence, now);\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
getScopedLogger
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-Q7MK5ZKG.js";
|
|
4
4
|
|
|
5
5
|
// src/observability/safety-check.ts
|
|
6
6
|
var MIN_PROD_SECRET_LENGTH = 32;
|
|
@@ -89,4 +89,4 @@ function isLoopbackUrl(url) {
|
|
|
89
89
|
export {
|
|
90
90
|
verifyStartupSafety
|
|
91
91
|
};
|
|
92
|
-
//# sourceMappingURL=chunk-
|
|
92
|
+
//# sourceMappingURL=chunk-MWLSXK6Y.js.map
|
|
@@ -12,7 +12,7 @@ var teeImportPromise = null;
|
|
|
12
12
|
async function teeToJobLog(level, message, context) {
|
|
13
13
|
try {
|
|
14
14
|
if (!teeImportPromise) {
|
|
15
|
-
teeImportPromise = import("./job-log-
|
|
15
|
+
teeImportPromise = import("./job-log-UY6ERPQZ.js");
|
|
16
16
|
}
|
|
17
17
|
const mod = await teeImportPromise;
|
|
18
18
|
if (mod.getCurrentJobId() !== null) {
|
|
@@ -65,4 +65,4 @@ export {
|
|
|
65
65
|
getScopedLogger,
|
|
66
66
|
resetLogger
|
|
67
67
|
};
|
|
68
|
-
//# sourceMappingURL=chunk-
|
|
68
|
+
//# sourceMappingURL=chunk-Q7MK5ZKG.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
findDocuments
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-TSCXXBOM.js";
|
|
4
4
|
import {
|
|
5
5
|
getI18nConfig
|
|
6
6
|
} from "./chunk-4ZLMEKFX.js";
|
|
@@ -597,4 +597,4 @@ export {
|
|
|
597
597
|
buildDiscussionForumPostingJsonLd,
|
|
598
598
|
buildPersonJsonLd
|
|
599
599
|
};
|
|
600
|
-
//# sourceMappingURL=chunk-
|
|
600
|
+
//# sourceMappingURL=chunk-QZ52U4ET.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
recordJobLog,
|
|
3
3
|
runInJobContext
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-CGLJBRRX.js";
|
|
5
5
|
import {
|
|
6
6
|
getEmailAdapter
|
|
7
7
|
} from "./chunk-LSHHRDVR.js";
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
} from "./chunk-OROPGO65.js";
|
|
17
17
|
import {
|
|
18
18
|
getLogger
|
|
19
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-Q7MK5ZKG.js";
|
|
20
20
|
import {
|
|
21
21
|
getDb
|
|
22
22
|
} from "./chunk-XANPEOJC.js";
|
|
@@ -145,7 +145,7 @@ function registerBuiltinHandlers() {
|
|
|
145
145
|
registerJobHandler("notifications:sendDigest", handleNotificationsSendDigest);
|
|
146
146
|
}
|
|
147
147
|
async function handleContentPublishScheduled(_) {
|
|
148
|
-
const { publishScheduledDocuments } = await import("./scheduled-
|
|
148
|
+
const { publishScheduledDocuments } = await import("./scheduled-C2IKVZVK.js");
|
|
149
149
|
const result = await publishScheduledDocuments();
|
|
150
150
|
if (result.published > 0) {
|
|
151
151
|
console.info(
|
|
@@ -193,7 +193,7 @@ async function handleMediaCleanup(data) {
|
|
|
193
193
|
async function handlePluginScheduledTask(data) {
|
|
194
194
|
if (isRecord(data) && typeof data.pluginId === "string" && typeof data.taskId === "string") {
|
|
195
195
|
try {
|
|
196
|
-
const { runPluginScheduledTask } = await import("./host-
|
|
196
|
+
const { runPluginScheduledTask } = await import("./host-HG4QGD3L.js");
|
|
197
197
|
await runPluginScheduledTask(data.pluginId, data.taskId);
|
|
198
198
|
return;
|
|
199
199
|
} catch (err) {
|
|
@@ -212,7 +212,7 @@ async function handleSessionCleanup(_) {
|
|
|
212
212
|
await builtinJobContext.cleanupSessions?.();
|
|
213
213
|
}
|
|
214
214
|
async function handleJobLogPrune(_) {
|
|
215
|
-
const { pruneJobLogsOlderThan: pruneJobLogsOlderThan2, DEFAULT_JOB_LOG_RETENTION_MS: DEFAULT_JOB_LOG_RETENTION_MS2 } = await import("./job-log-
|
|
215
|
+
const { pruneJobLogsOlderThan: pruneJobLogsOlderThan2, DEFAULT_JOB_LOG_RETENTION_MS: DEFAULT_JOB_LOG_RETENTION_MS2 } = await import("./job-log-UY6ERPQZ.js");
|
|
216
216
|
const cutoff = new Date(Date.now() - DEFAULT_JOB_LOG_RETENTION_MS2);
|
|
217
217
|
const deleted = await pruneJobLogsOlderThan2(cutoff);
|
|
218
218
|
if (deleted > 0) {
|
|
@@ -387,7 +387,7 @@ async function handleMemberSendPasswordReset(data) {
|
|
|
387
387
|
async function handleNotificationsSendDigest(data) {
|
|
388
388
|
const cadence = isRecord(data) && (data.cadence === "daily" || data.cadence === "weekly") ? data.cadence : "daily";
|
|
389
389
|
const siteName = isRecord(data) && typeof data.siteName === "string" ? data.siteName : void 0;
|
|
390
|
-
const { runDigestSweep } = await import("./digest-
|
|
390
|
+
const { runDigestSweep } = await import("./digest-IWHMJPXI.js");
|
|
391
391
|
const result = await runDigestSweep({ cadence, siteName });
|
|
392
392
|
console.info(
|
|
393
393
|
`[nexpress] notifications:sendDigest cadence=${cadence} considered=${result.considered} sent=${result.sent} skipped=${result.skipped} failed=${result.failed}`
|
|
@@ -673,7 +673,7 @@ var PgBossAdapter = class {
|
|
|
673
673
|
this.workRegistrations.push({ queueName, register });
|
|
674
674
|
await register();
|
|
675
675
|
}
|
|
676
|
-
const { getRegisteredPluginSchedules, runPluginScheduledTask } = await import("./host-
|
|
676
|
+
const { getRegisteredPluginSchedules, runPluginScheduledTask } = await import("./host-HG4QGD3L.js");
|
|
677
677
|
for (const schedule of getRegisteredPluginSchedules()) {
|
|
678
678
|
const queueName = `${toQueueName("plugin:scheduledTask")}.${schedule.pluginId}.${schedule.taskId}`;
|
|
679
679
|
await this.boss.createQueue(queueName);
|
|
@@ -775,7 +775,7 @@ var PgBossAdapter = class {
|
|
|
775
775
|
});
|
|
776
776
|
await this.boss.schedule(digestQueue, "0 8 * * *", { cadence: "daily" }, { key: "daily" });
|
|
777
777
|
await this.boss.schedule(digestQueue, "0 8 * * 1", { cadence: "weekly" }, { key: "weekly" });
|
|
778
|
-
const { getRegisteredPluginSchedules } = await import("./host-
|
|
778
|
+
const { getRegisteredPluginSchedules } = await import("./host-HG4QGD3L.js");
|
|
779
779
|
for (const schedule of getRegisteredPluginSchedules()) {
|
|
780
780
|
const pgBossName = `${toQueueName("plugin:scheduledTask")}.${schedule.pluginId}.${schedule.taskId}`;
|
|
781
781
|
await this.boss.schedule(pgBossName, schedule.cron, {
|
|
@@ -939,7 +939,7 @@ var PgBossAdapter = class {
|
|
|
939
939
|
* `workerOwnsRegistrations` so the admin UI can warn the operator.
|
|
940
940
|
*/
|
|
941
941
|
async reconcilePluginSchedules() {
|
|
942
|
-
const { getRegisteredPluginSchedules } = await import("./host-
|
|
942
|
+
const { getRegisteredPluginSchedules } = await import("./host-HG4QGD3L.js");
|
|
943
943
|
const wantedList = getRegisteredPluginSchedules();
|
|
944
944
|
const wantedByName = /* @__PURE__ */ new Map();
|
|
945
945
|
for (const schedule of wantedList) {
|
|
@@ -1267,4 +1267,4 @@ export {
|
|
|
1267
1267
|
stopWorker,
|
|
1268
1268
|
stopProducer
|
|
1269
1269
|
};
|
|
1270
|
-
//# sourceMappingURL=chunk-
|
|
1270
|
+
//# sourceMappingURL=chunk-SJ7M2VCC.js.map
|