feat: complete TASK-WP4-03 text templates
This commit is contained in:
@@ -87,6 +87,12 @@ import {
|
||||
ProjectStateSaveResponseSchema,
|
||||
ProjectSummarySchema,
|
||||
ProjectViewStatusSchema,
|
||||
RecentAssetItemSchema,
|
||||
RecentAssetKindSchema,
|
||||
RecentAssetListResponseSchema,
|
||||
RecentAssetQuerySchema,
|
||||
RecentAssetRecordRequestSchema,
|
||||
RecentAssetRecordResponseSchema,
|
||||
RegistrationCompleteHeadersSchema,
|
||||
RegistrationCompleteRequestSchema,
|
||||
RegistrationCompleteResponseSchema,
|
||||
@@ -120,6 +126,8 @@ import {
|
||||
type ProjectRenameRequest,
|
||||
type ProjectEditableState,
|
||||
type ProjectStateSaveHeaders,
|
||||
type RecentAssetQuery,
|
||||
type RecentAssetRecordRequest,
|
||||
type RegistrationCompleteRequest,
|
||||
type RegistrationSendRequest,
|
||||
} from "@dada/shared-contracts";
|
||||
@@ -162,6 +170,7 @@ import {
|
||||
registrationFieldError,
|
||||
} from "./registration-errors.js";
|
||||
import type { RegistrationService } from "./registration.js";
|
||||
import type { RecentAssetService } from "./recent-assets.js";
|
||||
import { ModelConfigurationError } from "./model-configuration.js";
|
||||
import type { ModelConfigurationService } from "./model-configuration.js";
|
||||
|
||||
@@ -189,6 +198,7 @@ export interface CreateAppOptions {
|
||||
models?: ModelConfigurationService;
|
||||
networkBoundary?: NetworkBoundaryOptions;
|
||||
publicAssets?: PublicAssetResolver;
|
||||
recentAssets?: RecentAssetService;
|
||||
projects?: ProjectService;
|
||||
registration?: RegistrationService;
|
||||
}
|
||||
@@ -726,6 +736,12 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
ProjectStateSaveHeadersSchema,
|
||||
ProjectStateSaveResponseSchema,
|
||||
ProjectStateConflictResponseSchema,
|
||||
RecentAssetKindSchema,
|
||||
RecentAssetItemSchema,
|
||||
RecentAssetQuerySchema,
|
||||
RecentAssetListResponseSchema,
|
||||
RecentAssetRecordRequestSchema,
|
||||
RecentAssetRecordResponseSchema,
|
||||
FailedEmptyTrashRequestSchema,
|
||||
FailedEmptyTrashResponseSchema,
|
||||
ModelIdSchema,
|
||||
@@ -810,6 +826,76 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/assets/recent",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
operationId: "listRecentAssets",
|
||||
querystring: Type.Ref(RecentAssetQuerySchema),
|
||||
response: {
|
||||
200: Type.Ref(RecentAssetListResponseSchema),
|
||||
400: Type.Null(),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Assets"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError) return reply.code(400).send(null);
|
||||
if (!options.registration || !options.recentAssets) {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
}
|
||||
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
|
||||
const session = token ? options.registration.readUserSession(token) : undefined;
|
||||
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
const query = request.query as RecentAssetQuery;
|
||||
return { items: options.recentAssets.list(session.userId, query.asset_kind) };
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/assets/recent",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
body: Type.Ref(RecentAssetRecordRequestSchema),
|
||||
headers: Type.Ref(CsrfHeadersSchema),
|
||||
operationId: "recordRecentAsset",
|
||||
response: {
|
||||
200: Type.Ref(RecentAssetRecordResponseSchema),
|
||||
400: Type.Null(),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Assets"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError) return reply.code(400).send(null);
|
||||
if (!options.registration || !options.recentAssets) {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
}
|
||||
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
|
||||
const csrfToken = headerValue(request.headers["x-csrf-token"]);
|
||||
if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
try {
|
||||
const owner = options.registration.authorizeUserMutation({ csrfToken, sessionToken: token });
|
||||
const body = request.body as RecentAssetRecordRequest;
|
||||
options.recentAssets.recordSuccessfulUse({
|
||||
assetId: body.asset_id,
|
||||
assetKind: body.asset_kind,
|
||||
resourceVersion: body.resource_version,
|
||||
userId: owner.userId,
|
||||
});
|
||||
return { status: "recorded" as const };
|
||||
} catch (error) {
|
||||
return registrationFailure(reply, request.id, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/admin-auth/login/send",
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@ import { LatestExportService } from "./latest-exports.js";
|
||||
import { CreditService } from "./credits.js";
|
||||
import { ProjectService } from "./projects.js";
|
||||
import { RegistrationService } from "./registration.js";
|
||||
import { RecentAssetService } from "./recent-assets.js";
|
||||
import { MockResendAdapter } from "./resend-adapter.js";
|
||||
import { readSecureConfigCandidate } from "./secure-config.js";
|
||||
import { StructuredJsonlLogger } from "./structured-log.js";
|
||||
@@ -24,6 +25,7 @@ let credits: CreditService | undefined;
|
||||
let storage: ManagedStorage | undefined;
|
||||
let latestExports: LatestExportService | undefined;
|
||||
let models: ModelConfigurationService | undefined;
|
||||
let recentAssets: RecentAssetService | undefined;
|
||||
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
|
||||
if (credentialChannelEnabled) {
|
||||
const clients = initializeApiCredentialClients(await receiveApiCredentials());
|
||||
@@ -47,6 +49,7 @@ if (credentialChannelEnabled) {
|
||||
storage = new ManagedStorage({ dataRoot, databasePath });
|
||||
latestExports = new LatestExportService({ databasePath, storage });
|
||||
models = new ModelConfigurationService({ database: registration.database });
|
||||
recentAssets = new RecentAssetService({ database: registration.database });
|
||||
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
|
||||
} catch (error) {
|
||||
latestExports?.close();
|
||||
@@ -73,6 +76,7 @@ const app = await createApp({
|
||||
...(models ? { models } : {}),
|
||||
...(projects ? { projects } : {}),
|
||||
...(registration ? { registration } : {}),
|
||||
...(recentAssets ? { recentAssets } : {}),
|
||||
});
|
||||
|
||||
await app.listen({
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createRequire } from "node:module";
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const Database = require("better-sqlite3") as typeof BetterSqlite3;
|
||||
|
||||
export type RecentAssetKind = "static_sticker" | "text_template";
|
||||
|
||||
export interface RecentAssetItem {
|
||||
asset_id: string;
|
||||
asset_kind: RecentAssetKind;
|
||||
resource_version: string;
|
||||
}
|
||||
|
||||
export class RecentAssetService {
|
||||
readonly database: BetterSqlite3.Database;
|
||||
private readonly ownsDatabase: boolean;
|
||||
private readonly clock: () => number;
|
||||
|
||||
constructor(input: { clock?: () => number; database?: BetterSqlite3.Database; databasePath?: string }) {
|
||||
if (!input.database && !input.databasePath) throw new Error("recent_asset_database_required");
|
||||
this.database = input.database ?? new Database(input.databasePath!);
|
||||
this.ownsDatabase = !input.database;
|
||||
this.clock = input.clock ?? Date.now;
|
||||
this.database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS recent_assets (
|
||||
user_id TEXT NOT NULL,
|
||||
asset_kind TEXT NOT NULL CHECK (asset_kind IN ('text_template', 'static_sticker')),
|
||||
asset_id TEXT NOT NULL,
|
||||
resource_version TEXT NOT NULL,
|
||||
used_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, asset_kind, asset_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS recent_assets_user_kind_used
|
||||
ON recent_assets (user_id, asset_kind, used_at DESC, asset_id ASC);
|
||||
`);
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.ownsDatabase) this.database.close();
|
||||
}
|
||||
|
||||
list(userId: string, assetKind: RecentAssetKind, limit = 12): RecentAssetItem[] {
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw new Error("recent_asset_limit_invalid");
|
||||
return this.database.prepare(`
|
||||
SELECT asset_id, asset_kind, resource_version
|
||||
FROM recent_assets
|
||||
WHERE user_id = ? AND asset_kind = ?
|
||||
ORDER BY used_at DESC, asset_id ASC
|
||||
LIMIT ?
|
||||
`).all(userId, assetKind, limit) as RecentAssetItem[];
|
||||
}
|
||||
|
||||
recordSuccessfulUse(input: { assetId: string; assetKind: RecentAssetKind; resourceVersion: string; userId: string }) {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$/.test(input.assetId)
|
||||
|| !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$/.test(input.resourceVersion)) {
|
||||
throw new Error("recent_asset_reference_invalid");
|
||||
}
|
||||
this.database.prepare(`
|
||||
INSERT INTO recent_assets (user_id, asset_kind, asset_id, resource_version, used_at)
|
||||
VALUES (@userId, @assetKind, @assetId, @resourceVersion, @usedAt)
|
||||
ON CONFLICT (user_id, asset_kind, asset_id) DO UPDATE SET
|
||||
resource_version = excluded.resource_version,
|
||||
used_at = excluded.used_at
|
||||
`).run({ ...input, usedAt: this.clock() });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user