From 3a3451ec54479f6da5ff2833a202922879326fcd Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sun, 11 Jan 2026 00:48:15 +0000 Subject: [PATCH] feat: add drum layouts table and service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements drum layouts feature with: - Database migration for drum_layouts table - Zod schema with layout_format containing drumCounts - DrumLayoutsRepo service layer - S3 handler functions for drum layout uploads - API endpoints: submit, complete, get, list, delete, download - TODO placeholder for file parsing/validation Closes #47 Co-authored-by: bitnimble 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- src/app/api/drum-layouts/[id]/delete/route.ts | 59 +++ .../api/drum-layouts/[id]/download/route.ts | 23 ++ src/app/api/drum-layouts/[id]/route.ts | 26 ++ src/app/api/drum-layouts/route.ts | 22 + .../drum-layouts/submit/complete/actions.ts | 99 +++++ src/app/api/drum-layouts/submit/route.ts | 98 +++++ src/schema/drum_layouts.ts | 82 ++++ src/services/db/id_gen.ts | 1 + .../drum_layouts/drum_layouts_repo.ts | 245 +++++++++++ src/services/maps/s3_handler.ts | 112 +++++ src/services/server_context.ts | 4 + src/services/zapatos/schema.d.ts | 385 +++++++++++++++++- .../20260111004023_add_drum_layouts.sql | 15 + 13 files changed, 1159 insertions(+), 12 deletions(-) create mode 100644 src/app/api/drum-layouts/[id]/delete/route.ts create mode 100644 src/app/api/drum-layouts/[id]/download/route.ts create mode 100644 src/app/api/drum-layouts/[id]/route.ts create mode 100644 src/app/api/drum-layouts/route.ts create mode 100644 src/app/api/drum-layouts/submit/complete/actions.ts create mode 100644 src/app/api/drum-layouts/submit/route.ts create mode 100644 src/schema/drum_layouts.ts create mode 100644 src/services/drum_layouts/drum_layouts_repo.ts create mode 100644 supabase/migrations/20260111004023_add_drum_layouts.sql diff --git a/src/app/api/drum-layouts/[id]/delete/route.ts b/src/app/api/drum-layouts/[id]/delete/route.ts new file mode 100644 index 0000000..e556a36 --- /dev/null +++ b/src/app/api/drum-layouts/[id]/delete/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { DeleteDrumLayoutResponse } from 'schema/drum_layouts'; +import { error } from 'services/helpers'; +import { getServerContext } from 'services/server_context'; +import { getUserSession } from 'services/session/session'; + +const send = (res: DeleteDrumLayoutResponse) => + NextResponse.json(DeleteDrumLayoutResponse.parse(res)); + +export async function POST( + _req: NextRequest, + props: { params: Promise<{ id: string }> } +): Promise { + const params = await props.params; + const { id } = params; + + const session = await getUserSession(); + if (!session) { + return error({ + statusCode: 403, + message: 'You must be logged in to delete drum layouts.', + errorBody: {}, + errorSerializer: DeleteDrumLayoutResponse.parse, + }); + } + + const { drumLayoutsRepo } = await getServerContext(); + const layoutResult = await drumLayoutsRepo.getDrumLayout(id); + if (!layoutResult.success) { + return error({ + statusCode: 404, + message: 'Drum layout not found.', + errorBody: {}, + errorSerializer: DeleteDrumLayoutResponse.parse, + }); + } + + if (layoutResult.value.uploader !== session.id) { + return error({ + statusCode: 403, + message: 'You are not authorized to delete this drum layout.', + errorBody: {}, + errorSerializer: DeleteDrumLayoutResponse.parse, + }); + } + + const deleteResult = await drumLayoutsRepo.deleteDrumLayout({ id }); + if (!deleteResult.success) { + return error({ + statusCode: 500, + message: 'Could not delete drum layout.', + errorBody: {}, + resultError: deleteResult, + errorSerializer: DeleteDrumLayoutResponse.parse, + }); + } + + return send({ success: true }); +} diff --git a/src/app/api/drum-layouts/[id]/download/route.ts b/src/app/api/drum-layouts/[id]/download/route.ts new file mode 100644 index 0000000..516231a --- /dev/null +++ b/src/app/api/drum-layouts/[id]/download/route.ts @@ -0,0 +1,23 @@ +import { getEnvVars } from 'services/env'; +import { getServerContext } from 'services/server_context'; +import { NextRequest, NextResponse } from 'next/server'; +import { redirect } from 'next/navigation'; + +export async function GET(_req: NextRequest, props: { params: Promise<{ id: string }> }) { + const params = await props.params; + const { id } = params; + const { drumLayoutsRepo } = await getServerContext(); + const result = await drumLayoutsRepo.getDrumLayout(id); + if (result.success === false) { + return new NextResponse('Drum layout not found', { status: 404 }); + } + + const filename = sanitizeForDownload(result.value.name); + redirect( + `${getEnvVars().publicS3BaseUrl}/drumLayouts/${result.value.id}.zip?title=${filename}.zip` + ); +} + +function sanitizeForDownload(filename: string) { + return filename.replace(/[^a-z0-9\-\(\)\[\]]/gi, '_'); +} diff --git a/src/app/api/drum-layouts/[id]/route.ts b/src/app/api/drum-layouts/[id]/route.ts new file mode 100644 index 0000000..54bf7c2 --- /dev/null +++ b/src/app/api/drum-layouts/[id]/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { GetDrumLayoutResponse } from 'schema/drum_layouts'; +import { getServerContext } from 'services/server_context'; + +const send = (res: GetDrumLayoutResponse) => NextResponse.json(GetDrumLayoutResponse.parse(res)); + +export async function GET( + _req: NextRequest, + props: { params: Promise<{ id: string }> } +): Promise { + const params = await props.params; + const { id } = params; + const { drumLayoutsRepo } = await getServerContext(); + const result = await drumLayoutsRepo.getDrumLayout(id); + if (!result.success) { + return send({ + success: false, + statusCode: 404, + errorMessage: 'Drum layout not found', + }); + } + return send({ + success: true, + drumLayout: result.value, + }); +} diff --git a/src/app/api/drum-layouts/route.ts b/src/app/api/drum-layouts/route.ts new file mode 100644 index 0000000..3e8bcb5 --- /dev/null +++ b/src/app/api/drum-layouts/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server'; +import { FindDrumLayoutsResponse } from 'schema/drum_layouts'; +import { getServerContext } from 'services/server_context'; + +const send = (res: FindDrumLayoutsResponse) => + NextResponse.json(FindDrumLayoutsResponse.parse(res)); + +export async function GET(): Promise { + const { drumLayoutsRepo } = await getServerContext(); + const result = await drumLayoutsRepo.findDrumLayouts({ by: 'all' }); + if (!result.success) { + return send({ + success: false, + statusCode: 500, + errorMessage: 'Could not fetch drum layouts', + }); + } + return send({ + success: true, + drumLayouts: result.value, + }); +} diff --git a/src/app/api/drum-layouts/submit/complete/actions.ts b/src/app/api/drum-layouts/submit/complete/actions.ts new file mode 100644 index 0000000..f288d36 --- /dev/null +++ b/src/app/api/drum-layouts/submit/complete/actions.ts @@ -0,0 +1,99 @@ +'use server'; + +import { MapValidity } from 'schema/drum_layouts'; +import { actionError } from 'services/helpers'; +import { submitDrumLayoutErrorMap } from 'services/drum_layouts/drum_layouts_repo'; +import { deleteDrumLayoutFiles, getDrumLayoutFile } from 'services/maps/s3_handler'; +import { getServerContext } from 'services/server_context'; +import { getUserSession } from 'services/session/session'; + +/** + * Handles the completion and validation of a new drum layout upload. This will publish the layout + * if it is valid. + * + * If it is a reupload, it will replace the existing layout data. If it is a new layout and it is + * invalid, it will rollback and delete the temporary DrumLayout record. + */ +export async function reportDrumLayoutUploadComplete(id: string) { + const { drumLayoutsRepo } = await getServerContext(); + + const validityResult = await drumLayoutsRepo.setValidity(id, MapValidity.UPLOADED); + if (!validityResult.success) { + return actionError({ + errorBody: {}, + message: 'Could not update drum layout upload status.', + resultError: validityResult, + }); + } + + const dbLayoutResult = await drumLayoutsRepo.getDrumLayout(id); + const previousValidity = dbLayoutResult.success ? dbLayoutResult.value.validity : undefined; + + async function cleanupFailedUpload() { + if (previousValidity === MapValidity.PENDING_UPLOAD) { + await drumLayoutsRepo.setValidity(id, MapValidity.INVALID); + await drumLayoutsRepo.deleteDrumLayout({ id }); + } else { + await deleteDrumLayoutFiles(id, true); + await drumLayoutsRepo.setValidity(id, MapValidity.VALID); + } + } + + const session = await getUserSession(); + if (!session) { + await cleanupFailedUpload(); + return actionError({ + errorBody: {}, + message: 'You must be logged in to submit drum layouts.', + }); + } + + if (dbLayoutResult.success && dbLayoutResult.value.uploader !== session.id) { + await cleanupFailedUpload(); + if (previousValidity === MapValidity.PENDING_UPLOAD) { + return actionError({ + errorBody: {}, + message: 'You must begin and complete the upload while logged into the same user session.', + }); + } else { + return actionError({ + errorBody: {}, + message: 'Only the original uploader can reupload a drum layout.', + }); + } + } + + const getFileResult = await getDrumLayoutFile(id, true); + if (!getFileResult.success) { + await cleanupFailedUpload(); + return actionError({ + errorBody: {}, + message: 'The file could not be processed.', + resultError: getFileResult, + }); + } + const layoutFile = getFileResult.value; + if (layoutFile.byteLength > 1024 * 1024 * 100) { + await cleanupFailedUpload(); + return actionError({ + message: 'File is over the filesize limit (100MB)', + errorBody: {}, + }); + } + const processResult = await drumLayoutsRepo.validateUploadedDrumLayout({ + id, + layoutFile, + uploader: session.id, + }); + if (!processResult.success) { + await cleanupFailedUpload(); + const [_statusCode, message] = submitDrumLayoutErrorMap[processResult.errors[0].type]; + return actionError({ + message: processResult.errors[0].userMessage || message, + errorBody: {}, + resultError: processResult, + }); + } + + return { success: true, value: processResult.value } as const; +} diff --git a/src/app/api/drum-layouts/submit/route.ts b/src/app/api/drum-layouts/submit/route.ts new file mode 100644 index 0000000..60a0c4c --- /dev/null +++ b/src/app/api/drum-layouts/submit/route.ts @@ -0,0 +1,98 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { + MapValidity, + SubmitDrumLayoutRequest, + SubmitDrumLayoutResponse, +} from 'schema/drum_layouts'; +import { error } from 'services/helpers'; +import { mintDrumLayoutUploadUrl } from 'services/maps/s3_handler'; +import { getServerContext } from 'services/server_context'; +import { getUserSession } from 'services/session/session'; + +const send = (res: SubmitDrumLayoutResponse) => + NextResponse.json(SubmitDrumLayoutResponse.parse(res)); + +/** + * Handles a request for uploading a new drum layout or reuploading an existing one. + * If it is a new layout, it creates a new temporary hidden DrumLayout record in the DB. + * + * This endpoint will return a presigned S3 upload URL and the layout ID. + * The client is expected to then upload to the specified URL and then call + * /api/drum-layouts/submit/complete, which will process/validate the layout and publish it. + */ +export async function POST(req: NextRequest): Promise { + const session = await getUserSession(); + if (!session) { + return error({ + statusCode: 403, + message: 'You must be logged in to submit drum layouts.', + errorBody: {}, + errorSerializer: SubmitDrumLayoutResponse.parse, + }); + } + + const submitReqResult = SubmitDrumLayoutRequest.safeParse(await req.json()); + if (!submitReqResult.success) { + console.log(JSON.stringify(submitReqResult.error)); + return error({ + statusCode: 400, + message: 'Invalid submit drum layout request.', + errorBody: {}, + errorSerializer: SubmitDrumLayoutResponse.parse, + }); + } + const submitReq = submitReqResult.data; + + const { drumLayoutsRepo } = await getServerContext(); + let id; + if (submitReq.id != null) { + const layoutResult = await drumLayoutsRepo.getDrumLayout(submitReq.id); + if (!layoutResult.success) { + return error({ + statusCode: 404, + message: `Could not find specified drum layout to resubmit: ${submitReq.id}`, + errorBody: {}, + errorSerializer: SubmitDrumLayoutResponse.parse, + }); + } + if (layoutResult.value.uploader !== session.id) { + return error({ + statusCode: 403, + message: `Not authorized to modify the specified drum layout: ${submitReq.id}`, + errorBody: {}, + errorSerializer: SubmitDrumLayoutResponse.parse, + }); + } + id = submitReq.id; + await drumLayoutsRepo.setValidity(id, MapValidity.PENDING_REUPLOAD); + } else { + const createResult = await drumLayoutsRepo.createNewDrumLayout({ + name: submitReq.name, + uploader: session.id, + }); + if (!createResult.success) { + return error({ + statusCode: 500, + message: 'Could not create placeholder drum layout when preparing for upload.', + errorBody: {}, + resultError: createResult, + errorSerializer: SubmitDrumLayoutResponse.parse, + }); + } + id = createResult.value.id; + } + const urlResp = await mintDrumLayoutUploadUrl(id); + if (!urlResp.success) { + return error({ + statusCode: 500, + message: 'Could not create the URL for uploading the drum layout.', + errorBody: {}, + errorSerializer: SubmitDrumLayoutResponse.parse, + }); + } + return send({ + success: true, + id, + url: urlResp.value, + }); +} diff --git a/src/schema/drum_layouts.ts b/src/schema/drum_layouts.ts new file mode 100644 index 0000000..7254ec9 --- /dev/null +++ b/src/schema/drum_layouts.ts @@ -0,0 +1,82 @@ +import { z } from 'zod'; +import { ApiError, ApiSuccess } from './api'; +import { MapValidity, MapVisibility } from './maps'; + +export { MapValidity, MapVisibility }; + +/* Layout Format Schema */ +export const DrumLayoutFormat = z.object({ + drumCounts: z.record(z.string(), z.number()), +}); +export type DrumLayoutFormat = z.infer; + +/* Structs */ +export const DrumLayout = z.object({ + id: z.string(), + visibility: z.nativeEnum(MapVisibility), + validity: z.nativeEnum(MapValidity), + submissionDate: z.coerce.date(), + name: z.string(), + description: z.string().nullish(), + uploader: z.string(), + imagePath: z.string().nullish(), + layoutFormat: DrumLayoutFormat, + downloadCount: z.number(), +}); +export type DrumLayout = z.infer; + +/* GET getDrumLayout */ +export const GetDrumLayoutSuccess = ApiSuccess.extend({ + drumLayout: DrumLayout, +}); +export type GetDrumLayoutSuccess = z.infer; + +export const GetDrumLayoutResponse = z.discriminatedUnion('success', [ + GetDrumLayoutSuccess, + ApiError, +]); +export type GetDrumLayoutResponse = z.infer; + +/* GET findDrumLayouts */ +export const FindDrumLayoutsSuccess = ApiSuccess.extend({ + drumLayouts: z.array(DrumLayout), +}); +export type FindDrumLayoutsSuccess = z.infer; + +export const FindDrumLayoutsResponse = z.discriminatedUnion('success', [ + FindDrumLayoutsSuccess, + ApiError, +]); +export type FindDrumLayoutsResponse = z.infer; + +/* POST submitDrumLayout */ +export const SubmitDrumLayoutRequest = z.object({ + name: z.string(), + id: z.string().optional(), +}); +export type SubmitDrumLayoutRequest = z.infer; + +export const SubmitDrumLayoutSuccess = ApiSuccess.extend({ + id: z.string(), + url: z.string(), +}); +export type SubmitDrumLayoutSuccess = z.infer; + +export const SubmitDrumLayoutError = ApiError.extend({}); +export type SubmitDrumLayoutError = z.infer; + +export const SubmitDrumLayoutResponse = z.discriminatedUnion('success', [ + SubmitDrumLayoutSuccess, + SubmitDrumLayoutError, +]); +export type SubmitDrumLayoutResponse = z.infer; + +/* DELETE deleteDrumLayout */ +export const DeleteDrumLayoutSuccess = ApiSuccess.extend({}); +export type DeleteDrumLayoutSuccess = z.infer; + +export const DeleteDrumLayoutResponse = z.discriminatedUnion('success', [ + DeleteDrumLayoutSuccess, + ApiError, +]); +export type DeleteDrumLayoutResponse = z.infer; diff --git a/src/services/db/id_gen.ts b/src/services/db/id_gen.ts index 6d5c820..f433bd1 100644 --- a/src/services/db/id_gen.ts +++ b/src/services/db/id_gen.ts @@ -4,6 +4,7 @@ const MAX_ID_GEN_ATTEMPTS = 10; export const enum IdDomain { USERS = 'U', MAPS = 'M', + DRUM_LAYOUTS = 'D', } const ID_LENGTH = 6; function idGen(domain: IdDomain) { diff --git a/src/services/drum_layouts/drum_layouts_repo.ts b/src/services/drum_layouts/drum_layouts_repo.ts new file mode 100644 index 0000000..74c15a2 --- /dev/null +++ b/src/services/drum_layouts/drum_layouts_repo.ts @@ -0,0 +1,245 @@ +import { PromisedResult, wrapError } from 'base/result'; +import { DrumLayout, DrumLayoutFormat, MapValidity, MapVisibility } from 'schema/drum_layouts'; +import { DbError, camelCaseKeys } from 'services/db/helpers'; +import snakeCaseKeys from 'snakecase-keys'; +import { IdDomain, generateId } from 'services/db/id_gen'; +import { getDbPool } from 'services/db/pool'; +import { getServerContext } from 'services/server_context'; +import * as db from 'zapatos/db'; +import { + S3Error, + deleteDrumLayoutFiles, + promoteTempDrumLayoutFiles, + uploadDrumLayoutImage, +} from 'services/maps/s3_handler'; + +export const enum GetDrumLayoutError { + MISSING_DRUM_LAYOUT = 'missing_drum_layout', + UNKNOWN_DB_ERROR = 'unknown_db_error', +} +export const enum UpdateDrumLayoutError { + UNKNOWN_DB_ERROR = 'unknown_db_error', +} +export const enum DeleteDrumLayoutError { + MISSING_DRUM_LAYOUT = 'missing_drum_layout', +} +export const enum CreateDrumLayoutError { + TOO_MANY_ID_GEN_ATTEMPTS = 'too_many_id_gen_attempts', +} +export const enum ValidateDrumLayoutError { + NO_DATA = 'no_data', + INVALID_FORMAT = 'invalid_format', +} + +type ProcessDrumLayoutOpts = { + id: string; + uploader: string; + layoutFile: Buffer; +}; + +export class DrumLayoutsRepo { + async findDrumLayouts( + findBy: { by: 'id'; ids: string[] } | { by: 'all' } + ): PromisedResult { + const pool = await getDbPool(); + + const whereable = findBy.by === 'id' ? { id: db.conditions.isIn(findBy.ids) } : {}; + + try { + const layouts = await db + .select('drum_layouts', { ...whereable, visibility: MapVisibility.PUBLIC }) + .run(pool); + return { + success: true, + value: layouts.map((l) => + DrumLayout.parse({ + ...camelCaseKeys(l), + layoutFormat: DrumLayoutFormat.parse(l.layout_format), + }) + ), + }; + } catch (e) { + return { success: false, errors: [wrapError(e, DbError.UNKNOWN_DB_ERROR)] }; + } + } + + async getDrumLayout(id: string): PromisedResult { + const { pool } = await getServerContext(); + try { + const layout = await db + .selectOne('drum_layouts', { id, visibility: MapVisibility.PUBLIC }) + .run(pool); + if (layout == null) { + return { success: false, errors: [{ type: GetDrumLayoutError.MISSING_DRUM_LAYOUT }] }; + } + return { + success: true, + value: DrumLayout.parse({ + ...camelCaseKeys(layout), + layoutFormat: DrumLayoutFormat.parse(layout.layout_format), + }), + }; + } catch (e) { + return { success: false, errors: [wrapError(e, GetDrumLayoutError.UNKNOWN_DB_ERROR)] }; + } + } + + async setValidity( + id: string, + validity: MapValidity + ): PromisedResult { + const pool = await getDbPool(); + try { + await db.update('drum_layouts', { validity }, { id }).run(pool); + return { success: true, value: undefined }; + } catch (e) { + return { success: false, errors: [wrapError(e, UpdateDrumLayoutError.UNKNOWN_DB_ERROR)] }; + } + } + + async deleteDrumLayout({ + id, + }: { + id: string; + }): PromisedResult { + const { pool } = await getServerContext(); + try { + const deleted = await db.deletes('drum_layouts', { id }).run(pool); + if (deleted.length === 0) { + return { success: false, errors: [{ type: DeleteDrumLayoutError.MISSING_DRUM_LAYOUT }] }; + } + await Promise.all([deleteDrumLayoutFiles(id, true), deleteDrumLayoutFiles(id, false)]); + return { success: true, value: undefined }; + } catch (e) { + return { success: false, errors: [wrapError(e, DbError.UNKNOWN_DB_ERROR)] }; + } + } + + async createNewDrumLayout({ + name, + uploader, + }: { + name: string; + uploader: string; + }): PromisedResult<{ id: string }, CreateDrumLayoutError | DbError> { + const pool = await getDbPool(); + const id = await generateId( + IdDomain.DRUM_LAYOUTS, + async (id) => !!(await db.selectOne('drum_layouts', { id }).run(pool)) + ); + if (id == null) { + return { success: false, errors: [{ type: CreateDrumLayoutError.TOO_MANY_ID_GEN_ATTEMPTS }] }; + } + + try { + await db + .insert('drum_layouts', [ + snakeCaseKeys({ + id, + visibility: MapVisibility.HIDDEN, + validity: MapValidity.PENDING_UPLOAD, + uploader, + submissionDate: new Date(), + name, + layoutFormat: {}, + }), + ]) + .run(pool); + return { success: true, value: { id } }; + } catch (e) { + return { success: false, errors: [wrapError(e, DbError.UNKNOWN_DB_ERROR)] }; + } + } + + async validateUploadedDrumLayout( + opts: ProcessDrumLayoutOpts + ): PromisedResult< + DrumLayout, + S3Error | DbError | CreateDrumLayoutError | ValidateDrumLayoutError + > { + const { id, layoutFile: _buffer, uploader } = opts; + await this.setValidity(id, MapValidity.VALIDATING); + + // TODO: Implement actual parsing and validation of the drum layout file. + // This should: + // 1. Unzip the _buffer + // 2. Parse the layout file format + // 3. Extract layout metadata (name, description, drumCounts, etc.) + // 4. Extract any image file for imagePath + // 5. Return validated data + + // For now, just return placeholder validated data + const validatedData = { + name: 'Placeholder Layout', + description: null as string | null, + layoutFormat: { drumCounts: {} } as DrumLayoutFormat, + imageFile: null as { buffer: Buffer; filename: string } | null, + }; + + // Upload image if present + let imagePath: string | null = null; + if (validatedData.imageFile) { + const uploadResult = await uploadDrumLayoutImage( + id, + validatedData.imageFile.buffer, + validatedData.imageFile.filename, + true + ); + if (!uploadResult.success) { + return uploadResult; + } + imagePath = uploadResult.value; + } + + const now = new Date(); + const pool = await getDbPool(); + try { + const insertedLayout = await db + .upsert( + 'drum_layouts', + snakeCaseKeys({ + id, + visibility: MapVisibility.PUBLIC, + validity: MapValidity.VALID, + submissionDate: now, + name: validatedData.name, + description: validatedData.description, + uploader, + imagePath, + layoutFormat: validatedData.layoutFormat, + }), + ['id'] + ) + .run(pool); + + await promoteTempDrumLayoutFiles(id); + + return { + success: true, + value: DrumLayout.parse({ + ...camelCaseKeys(insertedLayout), + layoutFormat: DrumLayoutFormat.parse(insertedLayout.layout_format), + }), + }; + } catch (e) { + return { success: false, errors: [wrapError(e, DbError.UNKNOWN_DB_ERROR)] }; + } + } +} + +const internalError: [number, string] = [500, 'Could not submit drum layout']; +export const submitDrumLayoutErrorMap: Record< + S3Error | DbError | CreateDrumLayoutError | ValidateDrumLayoutError, + [number, string] +> = { + [S3Error.S3_GET_ERROR]: internalError, + [S3Error.S3_WRITE_ERROR]: internalError, + [S3Error.S3_DELETE_ERROR]: internalError, + [DbError.UNKNOWN_DB_ERROR]: internalError, + [CreateDrumLayoutError.TOO_MANY_ID_GEN_ATTEMPTS]: internalError, + [ValidateDrumLayoutError.NO_DATA]: [400, 'Invalid drum layout archive; could not find data'], + [ValidateDrumLayoutError.INVALID_FORMAT]: [ + 400, + 'Invalid drum layout data; could not process the layout file', + ], +}; diff --git a/src/services/maps/s3_handler.ts b/src/services/maps/s3_handler.ts index 143d09e..ae88fea 100644 --- a/src/services/maps/s3_handler.ts +++ b/src/services/maps/s3_handler.ts @@ -21,6 +21,12 @@ let s3: { client: S3Client; bucket: string } | undefined; const mapKey = (id: string, temp: boolean) => `maps/${id}.zip` + (temp ? '.temp' : ''); const albumArtPrefix = (id: string, temp: boolean) => `albumArt/${id}` + (temp ? '_temp/' : '/'); +// Drum layouts use their own key paths +const drumLayoutKey = (id: string, temp: boolean) => + `drumLayouts/${id}.zip` + (temp ? '.temp' : ''); +const drumLayoutImagePrefix = (id: string, temp: boolean) => + `drumLayoutImages/${id}` + (temp ? '_temp/' : '/'); + export const enum S3Error { S3_GET_ERROR = 's3_get_error', S3_WRITE_ERROR = 's3_write_error', @@ -296,3 +302,109 @@ export async function promoteTempMapFiles(id: string): PromisedResult> { + const key = `${drumLayoutImagePrefix(id, temp)}${filename}`; + const result = await s3Put(key, imageBuffer, guessContentType(filename)); + if (!result.success) { + return result; + } + return { success: true, value: filename }; +} + +export async function deleteDrumLayoutFiles( + id: string, + temp: boolean +): Promise> { + const [layoutDeleteResult] = await Promise.all([ + s3Delete([drumLayoutKey(id, temp)]), + (async () => { + try { + const s3 = getS3Client(); + const imageFiles = await s3.client.send( + new ListObjectsV2Command({ + Bucket: s3.bucket, + Prefix: drumLayoutImagePrefix(id, temp), + }) + ); + await s3Delete( + imageFiles.Contents?.map((c) => c.Key).filter((k): k is string => k != null) || [] + ); + } catch { + // Ignore - file may not exist + } + })(), + ]); + + if (!layoutDeleteResult.success) { + return layoutDeleteResult; + } + return { success: true, value: undefined }; +} + +export async function promoteTempDrumLayoutFiles(id: string): PromisedResult { + await deleteDrumLayoutFiles(id, false); + + const moveResult = await s3Move(drumLayoutKey(id, true), drumLayoutKey(id, false)); + if (!moveResult.success) { + return moveResult; + } + + try { + const s3 = getS3Client(); + const imageFiles = await s3.client.send( + new ListObjectsV2Command({ + Bucket: s3.bucket, + Prefix: drumLayoutImagePrefix(id, true), + }) + ); + const imageKeys = + imageFiles.Contents?.map((c) => c.Key).filter((k): k is string => k != null) || []; + await Promise.all( + imageKeys.map((key) => + s3Move(key, key.replace(drumLayoutImagePrefix(id, true), drumLayoutImagePrefix(id, false))) + ) + ); + } catch (e) { + return { + success: false, + errors: [ + { + type: S3Error.S3_WRITE_ERROR, + internalMessage: (e as Error).message, + stack: (e as Error).stack, + }, + ], + }; + } + + return { success: true, value: undefined }; +} diff --git a/src/services/server_context.ts b/src/services/server_context.ts index fd41a3b..3cca88f 100644 --- a/src/services/server_context.ts +++ b/src/services/server_context.ts @@ -5,6 +5,7 @@ import { getEnvVars } from 'services/env'; import { getSingleton } from 'services/singleton'; import { Flags } from 'services/flags'; import { MapsRepo, MeilisearchMap } from 'services/maps/maps_repo'; +import { DrumLayoutsRepo } from 'services/drum_layouts/drum_layouts_repo'; import { FavoritesRepo } from 'services/users/favorites_repo'; import { createSupabaseServerClient } from './session/supabase_server'; import { rebuildMeilisearchIndex } from 'app/api/maps/search/rebuild/route'; @@ -13,6 +14,7 @@ type ServerContext = { pool: Pool; flags: Flags; mapsRepo: MapsRepo; + drumLayoutsRepo: DrumLayoutsRepo; favoritesRepo: FavoritesRepo; }; @@ -32,6 +34,7 @@ async function createServerContext(): Promise { } const mapsIndex = await meilisearch.getIndex('maps'); const mapsRepo = new MapsRepo(mapsIndex); + const drumLayoutsRepo = new DrumLayoutsRepo(); const favoritesRepo = new FavoritesRepo(mapsRepo, mapsIndex); const flags = getFlags(); @@ -39,6 +42,7 @@ async function createServerContext(): Promise { pool, flags, mapsRepo, + drumLayoutsRepo, favoritesRepo, }; } diff --git a/src/services/zapatos/schema.d.ts b/src/services/zapatos/schema.d.ts index 6ab4b52..49c08ef 100644 --- a/src/services/zapatos/schema.d.ts +++ b/src/services/zapatos/schema.d.ts @@ -22,6 +22,359 @@ declare module 'zapatos/schema' { /* --- tables --- */ + /** + * **drum_layouts** + * - Table in database + */ + export namespace drum_layouts { + export type Table = 'drum_layouts'; + export interface Selectable { + /** + * **drum_layouts._id** + * - `int4` in database + * - `NOT NULL`, default: `nextval('drum_layouts__id_seq'::regclass)` + */ + _id: number; + /** + * **drum_layouts.id** + * - `varchar` in database + * - `NOT NULL`, no default + */ + id: string; + /** + * **drum_layouts.visibility** + * - `bpchar` in database + * - `NOT NULL`, no default + */ + visibility: string; + /** + * **drum_layouts.validity** + * - `text` in database + * - `NOT NULL`, no default + */ + validity: string; + /** + * **drum_layouts.submission_date** + * - `timestamp` in database + * - `NOT NULL`, no default + */ + submission_date: Date; + /** + * **drum_layouts.name** + * - `varchar` in database + * - `NOT NULL`, no default + */ + name: string; + /** + * **drum_layouts.description** + * - `text` in database + * - Nullable, no default + */ + description: string | null; + /** + * **drum_layouts.uploader** + * - `varchar` in database + * - `NOT NULL`, no default + */ + uploader: string; + /** + * **drum_layouts.image_path** + * - `text` in database + * - Nullable, no default + */ + image_path: string | null; + /** + * **drum_layouts.layout_format** + * - `jsonb` in database + * - `NOT NULL`, default: `'{}'::jsonb` + */ + layout_format: db.JSONValue; + /** + * **drum_layouts.download_count** + * - `int4` in database + * - `NOT NULL`, default: `0` + */ + download_count: number; + } + export interface JSONSelectable { + /** + * **drum_layouts._id** + * - `int4` in database + * - `NOT NULL`, default: `nextval('drum_layouts__id_seq'::regclass)` + */ + _id: number; + /** + * **drum_layouts.id** + * - `varchar` in database + * - `NOT NULL`, no default + */ + id: string; + /** + * **drum_layouts.visibility** + * - `bpchar` in database + * - `NOT NULL`, no default + */ + visibility: string; + /** + * **drum_layouts.validity** + * - `text` in database + * - `NOT NULL`, no default + */ + validity: string; + /** + * **drum_layouts.submission_date** + * - `timestamp` in database + * - `NOT NULL`, no default + */ + submission_date: db.TimestampString; + /** + * **drum_layouts.name** + * - `varchar` in database + * - `NOT NULL`, no default + */ + name: string; + /** + * **drum_layouts.description** + * - `text` in database + * - Nullable, no default + */ + description: string | null; + /** + * **drum_layouts.uploader** + * - `varchar` in database + * - `NOT NULL`, no default + */ + uploader: string; + /** + * **drum_layouts.image_path** + * - `text` in database + * - Nullable, no default + */ + image_path: string | null; + /** + * **drum_layouts.layout_format** + * - `jsonb` in database + * - `NOT NULL`, default: `'{}'::jsonb` + */ + layout_format: db.JSONValue; + /** + * **drum_layouts.download_count** + * - `int4` in database + * - `NOT NULL`, default: `0` + */ + download_count: number; + } + export interface Whereable { + /** + * **drum_layouts._id** + * - `int4` in database + * - `NOT NULL`, default: `nextval('drum_layouts__id_seq'::regclass)` + */ + _id?: number | db.Parameter | db.SQLFragment | db.ParentColumn | db.SQLFragment | db.SQLFragment | db.ParentColumn>; + /** + * **drum_layouts.id** + * - `varchar` in database + * - `NOT NULL`, no default + */ + id?: string | db.Parameter | db.SQLFragment | db.ParentColumn | db.SQLFragment | db.SQLFragment | db.ParentColumn>; + /** + * **drum_layouts.visibility** + * - `bpchar` in database + * - `NOT NULL`, no default + */ + visibility?: string | db.Parameter | db.SQLFragment | db.ParentColumn | db.SQLFragment | db.SQLFragment | db.ParentColumn>; + /** + * **drum_layouts.validity** + * - `text` in database + * - `NOT NULL`, no default + */ + validity?: string | db.Parameter | db.SQLFragment | db.ParentColumn | db.SQLFragment | db.SQLFragment | db.ParentColumn>; + /** + * **drum_layouts.submission_date** + * - `timestamp` in database + * - `NOT NULL`, no default + */ + submission_date?: (db.TimestampString | Date) | db.Parameter<(db.TimestampString | Date)> | db.SQLFragment | db.ParentColumn | db.SQLFragment | db.SQLFragment | db.ParentColumn>; + /** + * **drum_layouts.name** + * - `varchar` in database + * - `NOT NULL`, no default + */ + name?: string | db.Parameter | db.SQLFragment | db.ParentColumn | db.SQLFragment | db.SQLFragment | db.ParentColumn>; + /** + * **drum_layouts.description** + * - `text` in database + * - Nullable, no default + */ + description?: string | db.Parameter | db.SQLFragment | db.ParentColumn | db.SQLFragment | db.SQLFragment | db.ParentColumn>; + /** + * **drum_layouts.uploader** + * - `varchar` in database + * - `NOT NULL`, no default + */ + uploader?: string | db.Parameter | db.SQLFragment | db.ParentColumn | db.SQLFragment | db.SQLFragment | db.ParentColumn>; + /** + * **drum_layouts.image_path** + * - `text` in database + * - Nullable, no default + */ + image_path?: string | db.Parameter | db.SQLFragment | db.ParentColumn | db.SQLFragment | db.SQLFragment | db.ParentColumn>; + /** + * **drum_layouts.layout_format** + * - `jsonb` in database + * - `NOT NULL`, default: `'{}'::jsonb` + */ + layout_format?: db.JSONValue | db.Parameter | db.SQLFragment | db.ParentColumn | db.SQLFragment | db.SQLFragment | db.ParentColumn>; + /** + * **drum_layouts.download_count** + * - `int4` in database + * - `NOT NULL`, default: `0` + */ + download_count?: number | db.Parameter | db.SQLFragment | db.ParentColumn | db.SQLFragment | db.SQLFragment | db.ParentColumn>; + } + export interface Insertable { + /** + * **drum_layouts._id** + * - `int4` in database + * - `NOT NULL`, default: `nextval('drum_layouts__id_seq'::regclass)` + */ + _id?: number | db.Parameter | db.DefaultType | db.SQLFragment; + /** + * **drum_layouts.id** + * - `varchar` in database + * - `NOT NULL`, no default + */ + id: string | db.Parameter | db.SQLFragment; + /** + * **drum_layouts.visibility** + * - `bpchar` in database + * - `NOT NULL`, no default + */ + visibility: string | db.Parameter | db.SQLFragment; + /** + * **drum_layouts.validity** + * - `text` in database + * - `NOT NULL`, no default + */ + validity: string | db.Parameter | db.SQLFragment; + /** + * **drum_layouts.submission_date** + * - `timestamp` in database + * - `NOT NULL`, no default + */ + submission_date: (db.TimestampString | Date) | db.Parameter<(db.TimestampString | Date)> | db.SQLFragment; + /** + * **drum_layouts.name** + * - `varchar` in database + * - `NOT NULL`, no default + */ + name: string | db.Parameter | db.SQLFragment; + /** + * **drum_layouts.description** + * - `text` in database + * - Nullable, no default + */ + description?: string | db.Parameter | null | db.DefaultType | db.SQLFragment; + /** + * **drum_layouts.uploader** + * - `varchar` in database + * - `NOT NULL`, no default + */ + uploader: string | db.Parameter | db.SQLFragment; + /** + * **drum_layouts.image_path** + * - `text` in database + * - Nullable, no default + */ + image_path?: string | db.Parameter | null | db.DefaultType | db.SQLFragment; + /** + * **drum_layouts.layout_format** + * - `jsonb` in database + * - `NOT NULL`, default: `'{}'::jsonb` + */ + layout_format?: db.JSONValue | db.Parameter | db.DefaultType | db.SQLFragment; + /** + * **drum_layouts.download_count** + * - `int4` in database + * - `NOT NULL`, default: `0` + */ + download_count?: number | db.Parameter | db.DefaultType | db.SQLFragment; + } + export interface Updatable { + /** + * **drum_layouts._id** + * - `int4` in database + * - `NOT NULL`, default: `nextval('drum_layouts__id_seq'::regclass)` + */ + _id?: number | db.Parameter | db.DefaultType | db.SQLFragment | db.SQLFragment | db.DefaultType | db.SQLFragment>; + /** + * **drum_layouts.id** + * - `varchar` in database + * - `NOT NULL`, no default + */ + id?: string | db.Parameter | db.SQLFragment | db.SQLFragment | db.SQLFragment>; + /** + * **drum_layouts.visibility** + * - `bpchar` in database + * - `NOT NULL`, no default + */ + visibility?: string | db.Parameter | db.SQLFragment | db.SQLFragment | db.SQLFragment>; + /** + * **drum_layouts.validity** + * - `text` in database + * - `NOT NULL`, no default + */ + validity?: string | db.Parameter | db.SQLFragment | db.SQLFragment | db.SQLFragment>; + /** + * **drum_layouts.submission_date** + * - `timestamp` in database + * - `NOT NULL`, no default + */ + submission_date?: (db.TimestampString | Date) | db.Parameter<(db.TimestampString | Date)> | db.SQLFragment | db.SQLFragment | db.SQLFragment>; + /** + * **drum_layouts.name** + * - `varchar` in database + * - `NOT NULL`, no default + */ + name?: string | db.Parameter | db.SQLFragment | db.SQLFragment | db.SQLFragment>; + /** + * **drum_layouts.description** + * - `text` in database + * - Nullable, no default + */ + description?: string | db.Parameter | null | db.DefaultType | db.SQLFragment | db.SQLFragment | null | db.DefaultType | db.SQLFragment>; + /** + * **drum_layouts.uploader** + * - `varchar` in database + * - `NOT NULL`, no default + */ + uploader?: string | db.Parameter | db.SQLFragment | db.SQLFragment | db.SQLFragment>; + /** + * **drum_layouts.image_path** + * - `text` in database + * - Nullable, no default + */ + image_path?: string | db.Parameter | null | db.DefaultType | db.SQLFragment | db.SQLFragment | null | db.DefaultType | db.SQLFragment>; + /** + * **drum_layouts.layout_format** + * - `jsonb` in database + * - `NOT NULL`, default: `'{}'::jsonb` + */ + layout_format?: db.JSONValue | db.Parameter | db.DefaultType | db.SQLFragment | db.SQLFragment | db.DefaultType | db.SQLFragment>; + /** + * **drum_layouts.download_count** + * - `int4` in database + * - `NOT NULL`, default: `0` + */ + download_count?: number | db.Parameter | db.DefaultType | db.SQLFragment | db.SQLFragment | db.DefaultType | db.SQLFragment>; + } + export type UniqueIndex = 'drum_layouts_pkey'; + export type Column = keyof Selectable; + export type OnlyCols = Pick; + export type SQLExpression = Table | db.ColumnNames | db.ColumnValues | Whereable | Column | db.ParentColumn | db.GenericSQLExpression; + export type SQL = SQLExpression | SQLExpression[]; + } + /** * **difficulties** * - Table in database @@ -926,21 +1279,21 @@ declare module 'zapatos/schema' { /* --- aggregate types --- */ - export namespace public { - export type Table = difficulties.Table | favorites.Table | maps.Table | users.Table; - export type Selectable = difficulties.Selectable | favorites.Selectable | maps.Selectable | users.Selectable; - export type JSONSelectable = difficulties.JSONSelectable | favorites.JSONSelectable | maps.JSONSelectable | users.JSONSelectable; - export type Whereable = difficulties.Whereable | favorites.Whereable | maps.Whereable | users.Whereable; - export type Insertable = difficulties.Insertable | favorites.Insertable | maps.Insertable | users.Insertable; - export type Updatable = difficulties.Updatable | favorites.Updatable | maps.Updatable | users.Updatable; - export type UniqueIndex = difficulties.UniqueIndex | favorites.UniqueIndex | maps.UniqueIndex | users.UniqueIndex; - export type Column = difficulties.Column | favorites.Column | maps.Column | users.Column; - - export type AllBaseTables = [difficulties.Table, favorites.Table, maps.Table, users.Table]; + export namespace public { + export type Table = drum_layouts.Table | difficulties.Table | favorites.Table | maps.Table | users.Table; + export type Selectable = drum_layouts.Selectable | difficulties.Selectable | favorites.Selectable | maps.Selectable | users.Selectable; + export type JSONSelectable = drum_layouts.JSONSelectable | difficulties.JSONSelectable | favorites.JSONSelectable | maps.JSONSelectable | users.JSONSelectable; + export type Whereable = drum_layouts.Whereable | difficulties.Whereable | favorites.Whereable | maps.Whereable | users.Whereable; + export type Insertable = drum_layouts.Insertable | difficulties.Insertable | favorites.Insertable | maps.Insertable | users.Insertable; + export type Updatable = drum_layouts.Updatable | difficulties.Updatable | favorites.Updatable | maps.Updatable | users.Updatable; + export type UniqueIndex = drum_layouts.UniqueIndex | difficulties.UniqueIndex | favorites.UniqueIndex | maps.UniqueIndex | users.UniqueIndex; + export type Column = drum_layouts.Column | difficulties.Column | favorites.Column | maps.Column | users.Column; + + export type AllBaseTables = [drum_layouts.Table, difficulties.Table, favorites.Table, maps.Table, users.Table]; export type AllForeignTables = []; export type AllViews = []; export type AllMaterializedViews = []; - export type AllTablesAndViews = [difficulties.Table, favorites.Table, maps.Table, users.Table]; + export type AllTablesAndViews = [drum_layouts.Table, difficulties.Table, favorites.Table, maps.Table, users.Table]; } @@ -968,6 +1321,7 @@ declare module 'zapatos/schema' { /* === lookups === */ export type SelectableForTable = { + "drum_layouts": drum_layouts.Selectable; "difficulties": difficulties.Selectable; "favorites": favorites.Selectable; "maps": maps.Selectable; @@ -975,6 +1329,7 @@ declare module 'zapatos/schema' { }[T]; export type JSONSelectableForTable = { + "drum_layouts": drum_layouts.JSONSelectable; "difficulties": difficulties.JSONSelectable; "favorites": favorites.JSONSelectable; "maps": maps.JSONSelectable; @@ -982,6 +1337,7 @@ declare module 'zapatos/schema' { }[T]; export type WhereableForTable = { + "drum_layouts": drum_layouts.Whereable; "difficulties": difficulties.Whereable; "favorites": favorites.Whereable; "maps": maps.Whereable; @@ -989,6 +1345,7 @@ declare module 'zapatos/schema' { }[T]; export type InsertableForTable = { + "drum_layouts": drum_layouts.Insertable; "difficulties": difficulties.Insertable; "favorites": favorites.Insertable; "maps": maps.Insertable; @@ -996,6 +1353,7 @@ declare module 'zapatos/schema' { }[T]; export type UpdatableForTable = { + "drum_layouts": drum_layouts.Updatable; "difficulties": difficulties.Updatable; "favorites": favorites.Updatable; "maps": maps.Updatable; @@ -1003,6 +1361,7 @@ declare module 'zapatos/schema' { }[T]; export type UniqueIndexForTable = { + "drum_layouts": drum_layouts.UniqueIndex; "difficulties": difficulties.UniqueIndex; "favorites": favorites.UniqueIndex; "maps": maps.UniqueIndex; @@ -1010,6 +1369,7 @@ declare module 'zapatos/schema' { }[T]; export type ColumnForTable = { + "drum_layouts": drum_layouts.Column; "difficulties": difficulties.Column; "favorites": favorites.Column; "maps": maps.Column; @@ -1017,6 +1377,7 @@ declare module 'zapatos/schema' { }[T]; export type SQLForTable = { + "drum_layouts": drum_layouts.SQL; "difficulties": difficulties.SQL; "favorites": favorites.SQL; "maps": maps.SQL; diff --git a/supabase/migrations/20260111004023_add_drum_layouts.sql b/supabase/migrations/20260111004023_add_drum_layouts.sql new file mode 100644 index 0000000..fc9827a --- /dev/null +++ b/supabase/migrations/20260111004023_add_drum_layouts.sql @@ -0,0 +1,15 @@ +CREATE TABLE drum_layouts ( + _id serial, + id varchar(16) primary key, + visibility char not null, + validity text not null, + submission_date timestamp not null, + name varchar(256) not null, + description text, + uploader varchar(256) not null, + image_path text, + layout_format jsonb not null default '{}'::jsonb, + download_count int not null default 0 +); + +alter table drum_layouts enable row level security;