diff --git a/bun.lockb b/bun.lockb index 1c165aa3..ba3c392e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/lib/redis-lock.ts b/lib/redis-lock.ts new file mode 100644 index 00000000..dfe1b25e --- /dev/null +++ b/lib/redis-lock.ts @@ -0,0 +1,40 @@ +'use server' +import { kv } from '@vercel/kv' +import { v4 as uuidv4 } from 'uuid' + +const LOCK_TIMEOUT = 30 * 1000 // 30 seconds + +export async function aquireLock(key: string): Promise { + const lockKey = `lock:${key}` + const lockValue = uuidv4() + const acquired = await kv.set(lockKey, lockValue, { + nx: true, + px: LOCK_TIMEOUT, + }) + return acquired ? lockValue : null +} + +export async function releaseLock( + lockKey: string, + lockValue: string, +): Promise { + const currentLockValue = await kv.get(lockKey) + if (currentLockValue === lockValue) { + await kv.del(lockKey) + } +} + +export async function withLock( + key: string, + action: () => Promise, +): Promise { + const lockValue = await aquireLock(key) + if (!lockValue) { + throw new Error(`Failed to acquire lock for key: ${key}`) + } + try { + return await action() + } finally { + await releaseLock(`lock:${key}`, lockValue) + } +} diff --git a/package.json b/package.json index ea348c80..610f836b 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,8 @@ "sharp": "^0.33.5", "shepherd.js": "^14.1.0", "tailwind-merge": "^2.5.2", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "uuid": "^11.0.3" }, "devDependencies": { "@types/node": "^20", diff --git a/src/app/harbor/shipyard/new-ship-form.tsx b/src/app/harbor/shipyard/new-ship-form.tsx index 218e58da..2e3fa0c5 100644 --- a/src/app/harbor/shipyard/new-ship-form.tsx +++ b/src/app/harbor/shipyard/new-ship-form.tsx @@ -129,7 +129,7 @@ export default function NewShipForm({ const isTutorial = sessionStorage?.getItem('tutorial') === 'true' if (!isTutorial) { - await createShip(formData) + await createShip(formData, false) } confettiRef.current?.addConfetti() closeForm() diff --git a/src/app/harbor/shipyard/ship-utils.ts b/src/app/harbor/shipyard/ship-utils.ts index 750afdba..21ccc074 100644 --- a/src/app/harbor/shipyard/ship-utils.ts +++ b/src/app/harbor/shipyard/ship-utils.ts @@ -6,6 +6,7 @@ import { fetchShips, person } from '@/app/utils/data' import { getWakaSessions } from '@/app/utils/waka' import type { Ship } from '@/app/utils/data' import Airtable from 'airtable' +import { withLock } from '../../../../lib/redis-lock' const peopleTableName = 'people' const shipsTableName = 'ships' @@ -41,34 +42,36 @@ export async function createShip(formData: FormData, isTutorial: boolean) { } const slackId = session.slackId - const entrantId = await getSelfPerson(slackId).then((p) => p.id) + return await withLock(`ship:${slackId}`, async () => { + const entrantId = await getSelfPerson(slackId).then((p) => p.id) - const isShipUpdate = formData.get('isShipUpdate') + const isShipUpdate = formData.get('isShipUpdate') - base()(shipsTableName).create( - [ - { - // @ts-expect-error No overload matches this call - but it does - fields: { - title: formData.get('title'), - entrant: [entrantId], - repo_url: formData.get('repo_url'), - readme_url: formData.get('readme_url'), - deploy_url: formData.get('deployment_url'), - screenshot_url: formData.get('screenshot_url'), - ship_type: isShipUpdate ? 'update' : 'project', - update_description: isShipUpdate - ? formData.get('updateDescription') - : null, - wakatime_project_name: formData.get('wakatime_project_name'), - project_source: isTutorial ? 'tutorial' : 'high_seas', + base()(shipsTableName).create( + [ + { + // @ts-expect-error No overload matches this call - but it does + fields: { + title: formData.get('title'), + entrant: [entrantId], + repo_url: formData.get('repo_url'), + readme_url: formData.get('readme_url'), + deploy_url: formData.get('deployment_url'), + screenshot_url: formData.get('screenshot_url'), + ship_type: isShipUpdate ? 'update' : 'project', + update_description: isShipUpdate + ? formData.get('updateDescription') + : null, + wakatime_project_name: formData.get('wakatime_project_name'), + project_source: isTutorial ? 'tutorial' : 'high_seas', + }, }, + ], + (err: Error, records: any) => { + if (err) console.error(err) }, - ], - (err: Error, records: any) => { - if (err) console.error(err) - }, - ) + ) + }) } // @malted: I'm confident this is secure. @@ -76,7 +79,7 @@ export async function createShipUpdate( dangerousReshippedFromShipId: string, credited_hours: number, formData: FormData, -): Promise { +): Promise { const session = await getSession() if (!session) { const error = new Error( @@ -87,102 +90,108 @@ export async function createShipUpdate( } const slackId = session.slackId - const entrantId = await getSelfPerson(slackId).then((p) => p.id) - // This pattern makes sure the ship data is not fraudulent - const ships = await fetchShips(slackId) + return withLock(`update:${slackId}`, async () => { + const entrantId = await getSelfPerson(slackId).then((p) => p.id) - const reshippedFromShip = ships.find( - (ship: Ship) => ship.id === dangerousReshippedFromShipId, - ) - if (!reshippedFromShip) { - const error = new Error('Invalid reshippedFromShipId!') - console.error(error) - throw error - } + // This pattern makes sure the ship data is not fraudulent + const ships = await fetchShips(slackId) - /* Two things are happening here. - * Firstly, the new ship of ship_type "update" needs to be created. - * - This will have all the same fields as the reshipped ship. - * - The update_descripton will be the new entered form field though. - * - The reshipped_from field should have the record ID of the reshipped ship - * Secondly, the reshipped_to field on the reshipped ship should be updated to be the new update ship's record ID. - */ + const reshippedFromShip = ships.find( + (ship: Ship) => ship.id === dangerousReshippedFromShipId, + ) + if (!reshippedFromShip) { + const error = new Error('Invalid reshippedFromShipId!') + console.error(error) + throw error + } + + /* Two things are happening here. + * Firstly, the new ship of ship_type "update" needs to be created. + * - This will have all the same fields as the reshipped ship. + * - The update_descripton will be the new entered form field though. + * - The reshipped_from field should have the record ID of the reshipped ship + * Secondly, the reshipped_to field on the reshipped ship should be updated to be the new update ship's record ID. + */ - // Step 1: - const res: { id: string; fields: any } = await new Promise( - (resolve, reject) => { - base()(shipsTableName).create( - [ - { - // @ts-expect-error No overload matches this call - but it does - fields: { - ...shipToFields(reshippedFromShip, entrantId), - ship_type: 'update', - update_description: formData.get('update_description'), - reshipped_from: [reshippedFromShip.id], - reshipped_from_all: reshippedFromShip.reshippedFromAll - ? [...reshippedFromShip.reshippedFromAll, reshippedFromShip.id] - : [reshippedFromShip.id], - credited_hours, + // Step 1: + const res: { id: string; fields: any } = await new Promise( + (resolve, reject) => { + base()(shipsTableName).create( + [ + { + // @ts-expect-error No overload matches this call - but it does + fields: { + ...shipToFields(reshippedFromShip, entrantId), + ship_type: 'update', + update_description: formData.get('update_description'), + reshipped_from: [reshippedFromShip.id], + reshipped_from_all: reshippedFromShip.reshippedFromAll + ? [ + ...reshippedFromShip.reshippedFromAll, + reshippedFromShip.id, + ] + : [reshippedFromShip.id], + credited_hours, + }, }, - }, - ], - (err: Error, records: any) => { - if (err) { - console.error('createShipUpdate step 1:', err) - throw err - } - if (records) { - // Step 2 - if (records.length !== 1) { - const error = new Error( - 'createShipUpdate: step 1 created records result length is not 1', - ) - console.error(error) - reject(error) + ], + (err: Error, records: any) => { + if (err) { + console.error('createShipUpdate step 1:', err) + throw err } - const reshippedToShip = records[0] + if (records) { + // Step 2 + if (records.length !== 1) { + const error = new Error( + 'createShipUpdate: step 1 created records result length is not 1', + ) + console.error(error) + reject(error) + } + const reshippedToShip = records[0] - // Update previous ship to point reshipped_to to the newly created update record - base()(shipsTableName).update([ - { - id: reshippedFromShip.id, - fields: { - reshipped_to: [reshippedToShip.id], - reshipped_all: [reshippedToShip, reshippedFromShip], + // Update previous ship to point reshipped_to to the newly created update record + base()(shipsTableName).update([ + { + id: reshippedFromShip.id, + fields: { + reshipped_to: [reshippedToShip.id], + reshipped_all: [reshippedToShip, reshippedFromShip], + }, }, - }, - ]) + ]) - resolve(reshippedToShip) - } else { - console.error('AAAFAUKCSCSAEVTNOESIFNVFEINTTET🤬🤬🤬') - reject(new Error('createShipUpdate: step 1 created no records')) - } - }, - ) - }, - ) + resolve(reshippedToShip) + } else { + console.error('AAAFAUKCSCSAEVTNOESIFNVFEINTTET🤬🤬🤬') + reject(new Error('createShipUpdate: step 1 created no records')) + } + }, + ) + }, + ) - return { - ...reshippedFromShip, - id: res.id, - repoUrl: reshippedFromShip.repoUrl, - readmeUrl: reshippedFromShip.readmeUrl, - screenshotUrl: reshippedFromShip.screenshotUrl, - deploymentUrl: reshippedFromShip.deploymentUrl, - shipType: 'update', - shipStatus: 'staged', - updateDescription: formData.get('update_description')?.toString() || null, - reshippedFromId: reshippedFromShip.id, - reshippedFromAll: reshippedFromShip.reshippedFromAll - ? [...reshippedFromShip.reshippedFromAll, reshippedFromShip.id] - : [reshippedFromShip.id], - credited_hours, - total_hours: (reshippedFromShip.total_hours ?? 0) + credited_hours, - wakatimeProjectNames: reshippedFromShip.wakatimeProjectNames, - } + return { + ...reshippedFromShip, + id: res.id, + repoUrl: reshippedFromShip.repoUrl, + readmeUrl: reshippedFromShip.readmeUrl, + screenshotUrl: reshippedFromShip.screenshotUrl, + deploymentUrl: reshippedFromShip.deploymentUrl, + shipType: 'update', + shipStatus: 'staged', + updateDescription: formData.get('update_description')?.toString() || null, + reshippedFromId: reshippedFromShip.id, + reshippedFromAll: reshippedFromShip.reshippedFromAll + ? [...reshippedFromShip.reshippedFromAll, reshippedFromShip.id] + : [reshippedFromShip.id], + credited_hours, + total_hours: (reshippedFromShip.total_hours ?? 0) + credited_hours, + wakatimeProjectNames: reshippedFromShip.wakatimeProjectNames, + } + }) } export async function updateShip(ship: Ship) {