🎉 init

This commit is contained in:
2026-07-01 15:43:18 +02:00
commit 2f492c55be
64 changed files with 11645 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
import { generateSlug } from '../../shared/utils/slug'
import { useDb } from '../database/client'
import { participants, pots } from '../database/schema'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const name = typeof body?.name === 'string' ? body.name.trim() : ''
const currency = typeof body?.currency === 'string' ? body.currency.trim().toUpperCase() : ''
const creatorName = typeof body?.creatorName === 'string' ? body.creatorName.trim() : ''
if (!name || !currency || !creatorName) {
throw createError({ statusCode: 400, statusMessage: 'name, currency and creatorName are required' })
}
const db = useDb()
const slug = generateSlug()
await db.insert(pots).values({ slug, name, currency })
const [creator] = await db.insert(participants).values({ potSlug: slug, name: creatorName }).returning()
return { slug, name, currency, creator }
})
+12
View File
@@ -0,0 +1,12 @@
import { calculateBalances } from '../../../shared/utils/balances'
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')!
const pot = await getPotOrThrow(slug)
const participants = await getParticipants(slug)
const expenses = await getExpensesWithParticipants(slug)
const { netBalances, settlements } = calculateBalances(participants, expenses)
return { pot, participants, expenses, netBalances, settlements }
})
+35
View File
@@ -0,0 +1,35 @@
import { useDb } from '../../../database/client'
import { expenseParticipants, expenses } from '../../../database/schema'
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')!
await getPotOrThrow(slug)
const body = await readBody(event)
const description = typeof body?.description === 'string' ? body.description.trim() : ''
const amountCents = Number(body?.amountCents)
const payerId = Number(body?.payerId)
const participantIds: number[] = Array.isArray(body?.participantIds) ? body.participantIds.map(Number) : []
if (!description) {
throw createError({ statusCode: 400, statusMessage: 'description is required' })
}
if (!Number.isInteger(amountCents) || amountCents <= 0) {
throw createError({ statusCode: 400, statusMessage: 'amountCents must be a positive integer' })
}
if (participantIds.length === 0) {
throw createError({ statusCode: 400, statusMessage: 'at least one participant must be selected' })
}
const potParticipants = await getParticipants(slug)
const validIds = new Set(potParticipants.map(p => p.id))
if (!validIds.has(payerId) || participantIds.some(id => !validIds.has(id))) {
throw createError({ statusCode: 400, statusMessage: 'payerId and participantIds must reference participants of this Pot' })
}
const db = useDb()
const [expense] = await db.insert(expenses).values({ potSlug: slug, description, amountCents, payerId }).returning()
await db.insert(expenseParticipants).values(participantIds.map(participantId => ({ expenseId: expense.id, participantId })))
return { ...expense, participantIds }
})
@@ -0,0 +1,20 @@
import { and, eq } from 'drizzle-orm'
import { useDb } from '../../../../database/client'
import { expenseParticipants, expenses } from '../../../../database/schema'
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')!
const id = Number(getRouterParam(event, 'id'))
await getPotOrThrow(slug)
const db = useDb()
const [existing] = await db.select().from(expenses).where(and(eq(expenses.id, id), eq(expenses.potSlug, slug)))
if (!existing) {
throw createError({ statusCode: 404, statusMessage: 'Expense not found' })
}
await db.delete(expenseParticipants).where(eq(expenseParticipants.expenseId, id))
await db.delete(expenses).where(eq(expenses.id, id))
return { success: true }
})
@@ -0,0 +1,43 @@
import { and, eq } from 'drizzle-orm'
import { useDb } from '../../../../database/client'
import { expenseParticipants, expenses } from '../../../../database/schema'
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')!
const id = Number(getRouterParam(event, 'id'))
await getPotOrThrow(slug)
const body = await readBody(event)
const description = typeof body?.description === 'string' ? body.description.trim() : ''
const amountCents = Number(body?.amountCents)
const payerId = Number(body?.payerId)
const participantIds: number[] = Array.isArray(body?.participantIds) ? body.participantIds.map(Number) : []
if (!description) {
throw createError({ statusCode: 400, statusMessage: 'description is required' })
}
if (!Number.isInteger(amountCents) || amountCents <= 0) {
throw createError({ statusCode: 400, statusMessage: 'amountCents must be a positive integer' })
}
if (participantIds.length === 0) {
throw createError({ statusCode: 400, statusMessage: 'at least one participant must be selected' })
}
const potParticipants = await getParticipants(slug)
const validIds = new Set(potParticipants.map(p => p.id))
if (!validIds.has(payerId) || participantIds.some(pid => !validIds.has(pid))) {
throw createError({ statusCode: 400, statusMessage: 'payerId and participantIds must reference participants of this Pot' })
}
const db = useDb()
const [existing] = await db.select().from(expenses).where(and(eq(expenses.id, id), eq(expenses.potSlug, slug)))
if (!existing) {
throw createError({ statusCode: 404, statusMessage: 'Expense not found' })
}
await db.update(expenses).set({ description, amountCents, payerId }).where(eq(expenses.id, id))
await db.delete(expenseParticipants).where(eq(expenseParticipants.expenseId, id))
await db.insert(expenseParticipants).values(participantIds.map(participantId => ({ expenseId: id, participantId })))
return { id, description, amountCents, payerId, participantIds }
})
@@ -0,0 +1,17 @@
import { useDb } from '../../../database/client'
import { participants } from '../../../database/schema'
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')!
await getPotOrThrow(slug)
const body = await readBody(event)
const name = typeof body?.name === 'string' ? body.name.trim() : ''
if (!name) {
throw createError({ statusCode: 400, statusMessage: 'name is required' })
}
const db = useDb()
const [participant] = await db.insert(participants).values({ potSlug: slug, name }).returning()
return participant
})
@@ -0,0 +1,29 @@
import { and, eq } from 'drizzle-orm'
import { isValidPaymentLink } from '../../../../../shared/utils/paymentLink'
import { useDb } from '../../../../database/client'
import { participants } from '../../../../database/schema'
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')!
const id = Number(getRouterParam(event, 'id'))
await getPotOrThrow(slug)
const body = await readBody(event)
const paymentLink = typeof body?.paymentLink === 'string' ? body.paymentLink.trim() : ''
if (paymentLink && !isValidPaymentLink(paymentLink)) {
throw createError({ statusCode: 400, statusMessage: 'paymentLink must be a valid http(s) URL' })
}
const db = useDb()
const [existing] = await db.select().from(participants).where(and(eq(participants.id, id), eq(participants.potSlug, slug)))
if (!existing) {
throw createError({ statusCode: 404, statusMessage: 'Participant not found' })
}
const [updated] = await db.update(participants)
.set({ paymentLink: paymentLink || null })
.where(eq(participants.id, id))
.returning()
return updated
})
+33
View File
@@ -0,0 +1,33 @@
import { useDb } from '../../../database/client'
import { expenseParticipants, expenses } from '../../../database/schema'
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')!
await getPotOrThrow(slug)
const body = await readBody(event)
const fromId = Number(body?.fromId)
const toId = Number(body?.toId)
const amountCents = Number(body?.amountCents)
if (!Number.isInteger(amountCents) || amountCents <= 0) {
throw createError({ statusCode: 400, statusMessage: 'amountCents must be a positive integer' })
}
if (fromId === toId) {
throw createError({ statusCode: 400, statusMessage: 'fromId and toId must differ' })
}
const potParticipants = await getParticipants(slug)
const validIds = new Set(potParticipants.map(p => p.id))
if (!validIds.has(fromId) || !validIds.has(toId)) {
throw createError({ statusCode: 400, statusMessage: 'fromId and toId must reference participants of this Pot' })
}
const db = useDb()
const [payment] = await db.insert(expenses)
.values({ potSlug: slug, type: 'payment', description: 'Settlement', amountCents, payerId: fromId })
.returning()
await db.insert(expenseParticipants).values({ expenseId: payment.id, participantId: toId })
return { ...payment, participantIds: [toId] }
})
+17
View File
@@ -0,0 +1,17 @@
import { createClient } from '@libsql/client'
import { drizzle } from 'drizzle-orm/libsql'
import * as schema from './schema'
let instance: ReturnType<typeof drizzle<typeof schema>> | undefined
export function useDb() {
if (!instance) {
const config = useRuntimeConfig()
const client = createClient({
url: config.databaseUrl,
authToken: config.databaseAuthToken || undefined,
})
instance = drizzle(client, { schema })
}
return instance
}
@@ -0,0 +1,35 @@
CREATE TABLE `expense_participants` (
`expense_id` integer NOT NULL,
`participant_id` integer NOT NULL,
FOREIGN KEY (`expense_id`) REFERENCES `expenses`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`participant_id`) REFERENCES `participants`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `expense_participants_expense_id_idx` ON `expense_participants` (`expense_id`);--> statement-breakpoint
CREATE TABLE `expenses` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`pot_slug` text NOT NULL,
`description` text NOT NULL,
`amount_cents` integer NOT NULL,
`payer_id` integer NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`pot_slug`) REFERENCES `pots`(`slug`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`payer_id`) REFERENCES `participants`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `expenses_pot_slug_idx` ON `expenses` (`pot_slug`);--> statement-breakpoint
CREATE TABLE `participants` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`pot_slug` text NOT NULL,
`name` text NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`pot_slug`) REFERENCES `pots`(`slug`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `participants_pot_slug_idx` ON `participants` (`pot_slug`);--> statement-breakpoint
CREATE TABLE `pots` (
`slug` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`currency` text NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL
);
@@ -0,0 +1,2 @@
ALTER TABLE `expenses` ADD `type` text DEFAULT 'expense' NOT NULL;--> statement-breakpoint
ALTER TABLE `participants` ADD `paypal_link` text;
@@ -0,0 +1 @@
ALTER TABLE `participants` RENAME COLUMN `paypal_link` TO `payment_link`;
@@ -0,0 +1,265 @@
{
"version": "6",
"dialect": "sqlite",
"id": "c81712c7-479e-4818-9604-5e603434afac",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"expense_participants": {
"name": "expense_participants",
"columns": {
"expense_id": {
"name": "expense_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"participant_id": {
"name": "participant_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"expense_participants_expense_id_idx": {
"name": "expense_participants_expense_id_idx",
"columns": [
"expense_id"
],
"isUnique": false
}
},
"foreignKeys": {
"expense_participants_expense_id_expenses_id_fk": {
"name": "expense_participants_expense_id_expenses_id_fk",
"tableFrom": "expense_participants",
"tableTo": "expenses",
"columnsFrom": [
"expense_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"expense_participants_participant_id_participants_id_fk": {
"name": "expense_participants_participant_id_participants_id_fk",
"tableFrom": "expense_participants",
"tableTo": "participants",
"columnsFrom": [
"participant_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"expenses": {
"name": "expenses",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"pot_slug": {
"name": "pot_slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"amount_cents": {
"name": "amount_cents",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"payer_id": {
"name": "payer_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"expenses_pot_slug_idx": {
"name": "expenses_pot_slug_idx",
"columns": [
"pot_slug"
],
"isUnique": false
}
},
"foreignKeys": {
"expenses_pot_slug_pots_slug_fk": {
"name": "expenses_pot_slug_pots_slug_fk",
"tableFrom": "expenses",
"tableTo": "pots",
"columnsFrom": [
"pot_slug"
],
"columnsTo": [
"slug"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"expenses_payer_id_participants_id_fk": {
"name": "expenses_payer_id_participants_id_fk",
"tableFrom": "expenses",
"tableTo": "participants",
"columnsFrom": [
"payer_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"participants": {
"name": "participants",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"pot_slug": {
"name": "pot_slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"participants_pot_slug_idx": {
"name": "participants_pot_slug_idx",
"columns": [
"pot_slug"
],
"isUnique": false
}
},
"foreignKeys": {
"participants_pot_slug_pots_slug_fk": {
"name": "participants_pot_slug_pots_slug_fk",
"tableFrom": "participants",
"tableTo": "pots",
"columnsFrom": [
"pot_slug"
],
"columnsTo": [
"slug"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"pots": {
"name": "pots",
"columns": {
"slug": {
"name": "slug",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"currency": {
"name": "currency",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
@@ -0,0 +1,280 @@
{
"version": "6",
"dialect": "sqlite",
"id": "ff3f0e2c-da64-4a0c-8c38-7b5717b112ac",
"prevId": "c81712c7-479e-4818-9604-5e603434afac",
"tables": {
"expense_participants": {
"name": "expense_participants",
"columns": {
"expense_id": {
"name": "expense_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"participant_id": {
"name": "participant_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"expense_participants_expense_id_idx": {
"name": "expense_participants_expense_id_idx",
"columns": [
"expense_id"
],
"isUnique": false
}
},
"foreignKeys": {
"expense_participants_expense_id_expenses_id_fk": {
"name": "expense_participants_expense_id_expenses_id_fk",
"tableFrom": "expense_participants",
"tableTo": "expenses",
"columnsFrom": [
"expense_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"expense_participants_participant_id_participants_id_fk": {
"name": "expense_participants_participant_id_participants_id_fk",
"tableFrom": "expense_participants",
"tableTo": "participants",
"columnsFrom": [
"participant_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"expenses": {
"name": "expenses",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"pot_slug": {
"name": "pot_slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'expense'"
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"amount_cents": {
"name": "amount_cents",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"payer_id": {
"name": "payer_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"expenses_pot_slug_idx": {
"name": "expenses_pot_slug_idx",
"columns": [
"pot_slug"
],
"isUnique": false
}
},
"foreignKeys": {
"expenses_pot_slug_pots_slug_fk": {
"name": "expenses_pot_slug_pots_slug_fk",
"tableFrom": "expenses",
"tableTo": "pots",
"columnsFrom": [
"pot_slug"
],
"columnsTo": [
"slug"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"expenses_payer_id_participants_id_fk": {
"name": "expenses_payer_id_participants_id_fk",
"tableFrom": "expenses",
"tableTo": "participants",
"columnsFrom": [
"payer_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"participants": {
"name": "participants",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"pot_slug": {
"name": "pot_slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"paypal_link": {
"name": "paypal_link",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"participants_pot_slug_idx": {
"name": "participants_pot_slug_idx",
"columns": [
"pot_slug"
],
"isUnique": false
}
},
"foreignKeys": {
"participants_pot_slug_pots_slug_fk": {
"name": "participants_pot_slug_pots_slug_fk",
"tableFrom": "participants",
"tableTo": "pots",
"columnsFrom": [
"pot_slug"
],
"columnsTo": [
"slug"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"pots": {
"name": "pots",
"columns": {
"slug": {
"name": "slug",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"currency": {
"name": "currency",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
@@ -0,0 +1,280 @@
{
"version": "6",
"dialect": "sqlite",
"id": "f2f62ba6-53b9-43b7-bbd2-3dd5bdaa21fe",
"prevId": "ff3f0e2c-da64-4a0c-8c38-7b5717b112ac",
"tables": {
"expense_participants": {
"name": "expense_participants",
"columns": {
"expense_id": {
"name": "expense_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"participant_id": {
"name": "participant_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"expense_participants_expense_id_idx": {
"name": "expense_participants_expense_id_idx",
"columns": [
"expense_id"
],
"isUnique": false
}
},
"foreignKeys": {
"expense_participants_expense_id_expenses_id_fk": {
"name": "expense_participants_expense_id_expenses_id_fk",
"tableFrom": "expense_participants",
"tableTo": "expenses",
"columnsFrom": [
"expense_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"expense_participants_participant_id_participants_id_fk": {
"name": "expense_participants_participant_id_participants_id_fk",
"tableFrom": "expense_participants",
"tableTo": "participants",
"columnsFrom": [
"participant_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"expenses": {
"name": "expenses",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"pot_slug": {
"name": "pot_slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'expense'"
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"amount_cents": {
"name": "amount_cents",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"payer_id": {
"name": "payer_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"expenses_pot_slug_idx": {
"name": "expenses_pot_slug_idx",
"columns": [
"pot_slug"
],
"isUnique": false
}
},
"foreignKeys": {
"expenses_pot_slug_pots_slug_fk": {
"name": "expenses_pot_slug_pots_slug_fk",
"tableFrom": "expenses",
"tableTo": "pots",
"columnsFrom": [
"pot_slug"
],
"columnsTo": [
"slug"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"expenses_payer_id_participants_id_fk": {
"name": "expenses_payer_id_participants_id_fk",
"tableFrom": "expenses",
"tableTo": "participants",
"columnsFrom": [
"payer_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"participants": {
"name": "participants",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"pot_slug": {
"name": "pot_slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"payment_link": {
"name": "payment_link",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"participants_pot_slug_idx": {
"name": "participants_pot_slug_idx",
"columns": [
"pot_slug"
],
"isUnique": false
}
},
"foreignKeys": {
"participants_pot_slug_pots_slug_fk": {
"name": "participants_pot_slug_pots_slug_fk",
"tableFrom": "participants",
"tableTo": "pots",
"columnsFrom": [
"pot_slug"
],
"columnsTo": [
"slug"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"pots": {
"name": "pots",
"columns": {
"slug": {
"name": "slug",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"currency": {
"name": "currency",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
@@ -0,0 +1,27 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1782902810997,
"tag": "0000_even_shinobi_shaw",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1782911489463,
"tag": "0001_careless_rhodey",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1782913249404,
"tag": "0002_rename_paypal_link",
"breakpoints": true
}
]
}
+41
View File
@@ -0,0 +1,41 @@
import { sql } from 'drizzle-orm'
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
export const pots = sqliteTable('pots', {
slug: text('slug').primaryKey(),
name: text('name').notNull(),
currency: text('currency').notNull(),
createdAt: integer('created_at').notNull().default(sql`(unixepoch())`),
})
export const participants = sqliteTable('participants', {
id: integer('id').primaryKey({ autoIncrement: true }),
potSlug: text('pot_slug').notNull().references(() => pots.slug),
name: text('name').notNull(),
paymentLink: text('payment_link'),
createdAt: integer('created_at').notNull().default(sql`(unixepoch())`),
}, table => [
index('participants_pot_slug_idx').on(table.potSlug),
])
export const expenses = sqliteTable('expenses', {
id: integer('id').primaryKey({ autoIncrement: true }),
potSlug: text('pot_slug').notNull().references(() => pots.slug),
// 'payment' rows are settlements: payerId is who paid, and the single row
// in expenseParticipants is who received it — same shape as a one-person
// split expense, which is mathematically what a settlement is.
type: text('type').notNull().default('expense'),
description: text('description').notNull(),
amountCents: integer('amount_cents').notNull(),
payerId: integer('payer_id').notNull().references(() => participants.id),
createdAt: integer('created_at').notNull().default(sql`(unixepoch())`),
}, table => [
index('expenses_pot_slug_idx').on(table.potSlug),
])
export const expenseParticipants = sqliteTable('expense_participants', {
expenseId: integer('expense_id').notNull().references(() => expenses.id),
participantId: integer('participant_id').notNull().references(() => participants.id),
}, table => [
index('expense_participants_expense_id_idx').on(table.expenseId),
])
+6
View File
@@ -0,0 +1,6 @@
import { migrate } from 'drizzle-orm/libsql/migrator'
import { useDb } from '../database/client'
export default defineNitroPlugin(async () => {
await migrate(useDb(), { migrationsFolder: 'server/database/migrations' })
})
+53
View File
@@ -0,0 +1,53 @@
import { eq, inArray } from 'drizzle-orm'
import { useDb } from '../database/client'
import { expenseParticipants, expenses, participants, pots } from '../database/schema'
export interface ExpenseWithDetails {
id: number
type: string
description: string
amountCents: number
payerId: number
participantIds: number[]
createdAt: number
}
export async function getPotOrThrow(slug: string) {
const db = useDb()
const [pot] = await db.select().from(pots).where(eq(pots.slug, slug))
if (!pot) {
throw createError({ statusCode: 404, statusMessage: 'Pot not found' })
}
return pot
}
export async function getParticipants(slug: string) {
const db = useDb()
return db.select().from(participants).where(eq(participants.potSlug, slug))
}
export async function getExpensesWithParticipants(slug: string): Promise<ExpenseWithDetails[]> {
const db = useDb()
const expenseRows = await db.select().from(expenses).where(eq(expenses.potSlug, slug)).orderBy(expenses.createdAt)
if (expenseRows.length === 0) return []
const expenseIds = expenseRows.map(row => row.id)
const links = await db.select().from(expenseParticipants).where(inArray(expenseParticipants.expenseId, expenseIds))
const participantIdsByExpense = new Map<number, number[]>()
for (const link of links) {
const list = participantIdsByExpense.get(link.expenseId) ?? []
list.push(link.participantId)
participantIdsByExpense.set(link.expenseId, list)
}
return expenseRows.map(row => ({
id: row.id,
type: row.type,
description: row.description,
amountCents: row.amountCents,
payerId: row.payerId,
participantIds: participantIdsByExpense.get(row.id) ?? [],
createdAt: row.createdAt,
}))
}