feat: complete TASK-WP2-01 project foundations
This commit is contained in:
@@ -29,6 +29,20 @@ import {
|
||||
LogoutResponseSchema,
|
||||
ModelConfigSseEventSchema,
|
||||
ModelRuntimeSseEventSchema,
|
||||
FailedEmptyTrashRequestSchema,
|
||||
FailedEmptyTrashResponseSchema,
|
||||
GenerationProjectItemSchema,
|
||||
ProjectDetailResponseSchema,
|
||||
ProjectIdSchema,
|
||||
ProjectImageItemSchema,
|
||||
ProjectListQuerySchema,
|
||||
ProjectListResponseSchema,
|
||||
ProjectParamsSchema,
|
||||
ProjectRatioSchema,
|
||||
ProjectRenameRequestSchema,
|
||||
ProjectRenameResponseSchema,
|
||||
ProjectSummarySchema,
|
||||
ProjectViewStatusSchema,
|
||||
RegistrationCompleteHeadersSchema,
|
||||
RegistrationCompleteRequestSchema,
|
||||
RegistrationCompleteResponseSchema,
|
||||
@@ -48,6 +62,10 @@ import {
|
||||
type AccountProfileUpdateRequest,
|
||||
type LoginCompleteRequest,
|
||||
type LoginSendRequest,
|
||||
type FailedEmptyTrashRequest,
|
||||
type ProjectListQuery,
|
||||
type ProjectParams,
|
||||
type ProjectRenameRequest,
|
||||
type RegistrationCompleteRequest,
|
||||
type RegistrationSendRequest,
|
||||
} from "@dada/shared-contracts";
|
||||
@@ -70,6 +88,8 @@ import {
|
||||
import { EventHub } from "./event-hub.js";
|
||||
import type { PublicAssetResolver } from "./local-data-root.js";
|
||||
import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js";
|
||||
import { ProjectError } from "./project-errors.js";
|
||||
import type { ProjectService } from "./projects.js";
|
||||
import {
|
||||
RegistrationError,
|
||||
registrationFieldError,
|
||||
@@ -96,6 +116,7 @@ export interface CreateAppOptions {
|
||||
eventHub?: EventHub;
|
||||
networkBoundary?: NetworkBoundaryOptions;
|
||||
publicAssets?: PublicAssetResolver;
|
||||
projects?: ProjectService;
|
||||
registration?: RegistrationService;
|
||||
}
|
||||
|
||||
@@ -163,6 +184,64 @@ function registrationValidationFailure(reply: FastifyReply, correlationId: strin
|
||||
);
|
||||
}
|
||||
|
||||
function projectFailure(reply: FastifyReply, correlationId: string, error: unknown) {
|
||||
if (!(error instanceof ProjectError)) {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId }));
|
||||
}
|
||||
const mapping = {
|
||||
generation_state_invalid: 400,
|
||||
project_active_limit: 409,
|
||||
project_history_limit: 409,
|
||||
project_name_invalid: 400,
|
||||
project_not_found: 404,
|
||||
project_ratio_fixed: 409,
|
||||
project_retry_not_allowed: 409,
|
||||
} as const;
|
||||
return reply.code(mapping[error.code]).send(null);
|
||||
}
|
||||
|
||||
type ProjectSummaryView = ReturnType<ProjectService["listProjects"]>[number];
|
||||
type ProjectDetailView = ReturnType<ProjectService["getProject"]>;
|
||||
|
||||
function projectSummaryResponse(project: ProjectSummaryView) {
|
||||
return {
|
||||
current_image_id: project.currentImageId,
|
||||
deleted_at: project.deletedAt,
|
||||
name: project.name,
|
||||
project_id: project.projectId,
|
||||
purge_at: project.purgeAt,
|
||||
ratio: project.ratio,
|
||||
state_version: project.stateVersion,
|
||||
status: project.status,
|
||||
successful_image_count: project.successfulImageCount,
|
||||
updated_at: project.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function projectDetailResponse(project: ProjectDetailView) {
|
||||
return {
|
||||
...projectSummaryResponse(project),
|
||||
created_at: project.createdAt,
|
||||
draft_prompt: project.draftPrompt,
|
||||
generations: project.generations.map((generation) => ({
|
||||
created_at: generation.createdAt,
|
||||
error_category: generation.errorCategory,
|
||||
generation_id: generation.generationId,
|
||||
prompt: generation.prompt,
|
||||
ratio: generation.ratio,
|
||||
status: generation.status,
|
||||
updated_at: generation.updatedAt,
|
||||
})),
|
||||
images: project.images.map((image) => ({
|
||||
created_at: image.createdAt,
|
||||
generation_id: image.generationId,
|
||||
image_id: image.imageId,
|
||||
})),
|
||||
pixel_height: project.pixelHeight,
|
||||
pixel_width: project.pixelWidth,
|
||||
};
|
||||
}
|
||||
|
||||
function isSupportGateRequest(method: string, path: string) {
|
||||
if (method === "POST" && path === "/api/v1/support/check") return true;
|
||||
if (method !== "GET" && method !== "HEAD") return false;
|
||||
@@ -261,6 +340,20 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
ModelConfigSseEventSchema,
|
||||
ModelRuntimeSseEventSchema,
|
||||
SseEventSchema,
|
||||
ProjectIdSchema,
|
||||
ProjectRatioSchema,
|
||||
ProjectViewStatusSchema,
|
||||
ProjectSummarySchema,
|
||||
ProjectListQuerySchema,
|
||||
ProjectListResponseSchema,
|
||||
ProjectParamsSchema,
|
||||
GenerationProjectItemSchema,
|
||||
ProjectImageItemSchema,
|
||||
ProjectDetailResponseSchema,
|
||||
ProjectRenameRequestSchema,
|
||||
ProjectRenameResponseSchema,
|
||||
FailedEmptyTrashRequestSchema,
|
||||
FailedEmptyTrashResponseSchema,
|
||||
]) {
|
||||
app.addSchema(schema);
|
||||
}
|
||||
@@ -920,6 +1013,163 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/projects",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
operationId: "listProjects",
|
||||
querystring: Type.Ref(ProjectListQuerySchema),
|
||||
response: {
|
||||
200: Type.Ref(ProjectListResponseSchema),
|
||||
400: Type.Null(),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Projects"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError) {
|
||||
return reply.code(400).send(null);
|
||||
}
|
||||
if (!options.registration || !options.projects) {
|
||||
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 ProjectListQuery;
|
||||
const status = query.status ?? "active";
|
||||
return {
|
||||
active_count: options.projects.activeProjectCount(session.userId),
|
||||
active_limit: 20 as const,
|
||||
projects: options.projects.listProjects(session.userId, status).slice(0, 20).map(projectSummaryResponse),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/projects/:projectId",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
operationId: "getProject",
|
||||
params: Type.Ref(ProjectParamsSchema),
|
||||
response: {
|
||||
200: Type.Ref(ProjectDetailResponseSchema),
|
||||
400: Type.Null(),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
404: Type.Null(),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Projects"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError) {
|
||||
return reply.code(400).send(null);
|
||||
}
|
||||
if (!options.registration || !options.projects) {
|
||||
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 }));
|
||||
try {
|
||||
return projectDetailResponse(options.projects.getProject(session.userId, (request.params as ProjectParams).projectId));
|
||||
} catch (error) {
|
||||
return projectFailure(reply, request.id, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/api/v1/projects/:projectId",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
body: Type.Ref(ProjectRenameRequestSchema),
|
||||
headers: Type.Ref(CsrfHeadersSchema),
|
||||
operationId: "renameProject",
|
||||
params: Type.Ref(ProjectParamsSchema),
|
||||
response: {
|
||||
200: Type.Ref(ProjectRenameResponseSchema),
|
||||
400: Type.Null(),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
403: Type.Ref(ErrorEnvelopeSchema),
|
||||
404: Type.Null(),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Projects"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError) {
|
||||
return reply.code(400).send(null);
|
||||
}
|
||||
if (!options.registration || !options.projects) {
|
||||
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 renamed = options.projects.renameProject(
|
||||
owner.userId,
|
||||
(request.params as ProjectParams).projectId,
|
||||
(request.body as ProjectRenameRequest).name,
|
||||
);
|
||||
return { name: renamed.name, state_version: renamed.stateVersion, status: "renamed" as const };
|
||||
} catch (error) {
|
||||
return error instanceof RegistrationError
|
||||
? registrationFailure(reply, request.id, error)
|
||||
: projectFailure(reply, request.id, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/projects/failed-empty/trash",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
body: Type.Ref(FailedEmptyTrashRequestSchema),
|
||||
headers: Type.Ref(CsrfHeadersSchema),
|
||||
operationId: "trashFailedEmptyProjects",
|
||||
response: {
|
||||
200: Type.Ref(FailedEmptyTrashResponseSchema),
|
||||
400: Type.Null(),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
403: Type.Ref(ErrorEnvelopeSchema),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Projects"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError) {
|
||||
return reply.code(400).send(null);
|
||||
}
|
||||
if (!options.registration || !options.projects) {
|
||||
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 result = options.projects.trashFailedEmpty(owner.userId, (request.body as FailedEmptyTrashRequest).project_ids);
|
||||
return { ignored_project_ids: result.ignoredProjectIds, trashed_project_ids: result.trashedProjectIds };
|
||||
} catch (error) {
|
||||
return error instanceof RegistrationError
|
||||
? registrationFailure(reply, request.id, error)
|
||||
: projectFailure(reply, request.id, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/support/check",
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user