feat: add auth and database foundation

This commit is contained in:
2026-06-05 23:25:23 +01:00
parent 6c3cef4da2
commit 7bb19e1be3
35 changed files with 7497 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
import { z } from 'zod';
const passwordMessage = 'Password must be at least 8 characters.';
const optionalPassword = z.string().refine((value) => value.length === 0 || value.length >= 8, {
message: passwordMessage
});
export const createAdminSchema = z.object({
name: z.string().trim().min(1, 'Enter a name.'),
email: z.email('Enter a valid email address.').transform((value) => value.toLowerCase()),
password: z.string().min(8, passwordMessage)
});
export const editAdminSchema = z.object({
id: z.string().min(1, 'Admin id is required.'),
name: z.string().trim().min(1, 'Enter a name.'),
email: z.email('Enter a valid email address.').transform((value) => value.toLowerCase()),
password: optionalPassword
});
export const deleteAdminSchema = z.object({
id: z.string().min(1, 'Admin id is required.')
});
export type CreateAdminSchema = typeof createAdminSchema;
export type EditAdminSchema = typeof editAdminSchema;
export type DeleteAdminSchema = typeof deleteAdminSchema;
+8
View File
@@ -0,0 +1,8 @@
import { z } from 'zod';
export const loginSchema = z.object({
email: z.email('Enter a valid email address.'),
password: z.string().min(1, 'Enter your password.')
});
export type LoginSchema = typeof loginSchema;
+19
View File
@@ -0,0 +1,19 @@
import { betterAuth } from 'better-auth/minimal';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { sveltekitCookies } from 'better-auth/svelte-kit';
import { env } from '$env/dynamic/private';
import { getRequestEvent } from '$app/server';
import { db } from '$lib/server/db';
export const auth = betterAuth({
baseURL: env.ORIGIN,
secret: env.BETTER_AUTH_SECRET,
database: drizzleAdapter(db, { provider: 'sqlite' }),
emailAndPassword: {
enabled: true,
disableSignUp: true
},
plugins: [
sveltekitCookies(getRequestEvent) // make sure this is the last plugin in the array
]
});
+25
View File
@@ -0,0 +1,25 @@
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { clients } from './clients.schema';
import { timestamps } from './shared.schema';
export const addresses = sqliteTable(
'addresses',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
clientId: text('client_id')
.notNull()
.references(() => clients.id, { onDelete: 'restrict' }),
label: text('label').notNull(),
line1: text('line_1').notNull(),
line2: text('line_2'),
city: text('city').notNull(),
region: text('region'),
postcode: text('postcode').notNull(),
country: text('country').notNull().default('United Kingdom'),
isPrimary: integer('is_primary', { mode: 'boolean' }).notNull().default(false),
...timestamps
},
(table) => [index('addresses_client_id_idx').on(table.clientId)]
);
+105
View File
@@ -0,0 +1,105 @@
import { relations, sql } from 'drizzle-orm';
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
export const user = sqliteTable('user', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: integer('email_verified', { mode: 'boolean' }).default(false).notNull(),
image: text('image'),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull()
});
export const session = sqliteTable(
'session',
{
id: text('id').primaryKey(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
token: text('token').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' })
},
(table) => [index('session_userId_idx').on(table.userId)]
);
export const account = sqliteTable(
'account',
{
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: integer('access_token_expires_at', {
mode: 'timestamp_ms'
}),
refreshTokenExpiresAt: integer('refresh_token_expires_at', {
mode: 'timestamp_ms'
}),
scope: text('scope'),
password: text('password'),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull()
},
(table) => [index('account_userId_idx').on(table.userId)]
);
export const verification = sqliteTable(
'verification',
{
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull()
},
(table) => [index('verification_identifier_idx').on(table.identifier)]
);
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account)
}));
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id]
})
}));
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id]
})
}));
+31
View File
@@ -0,0 +1,31 @@
import { index, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { clients } from './clients.schema';
import { rooms } from './rooms.schema';
import { services } from './services.schema';
import { timestamps } from './shared.schema';
export const bookings = sqliteTable(
'bookings',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
clientId: text('client_id')
.notNull()
.references(() => clients.id, { onDelete: 'restrict' }),
roomId: text('room_id')
.notNull()
.references(() => rooms.id, { onDelete: 'restrict' }),
serviceId: text('service_id').references(() => services.id, { onDelete: 'set null' }),
startsAt: text('starts_at').notNull(),
endsAt: text('ends_at').notNull(),
status: text('status').notNull().default('booked'),
notes: text('notes'),
...timestamps
},
(table) => [
index('bookings_client_id_idx').on(table.clientId),
index('bookings_room_id_idx').on(table.roomId),
index('bookings_service_id_idx').on(table.serviceId)
]
);
+14
View File
@@ -0,0 +1,14 @@
import { text, sqliteTable } from 'drizzle-orm/sqlite-core';
import { timestamps } from './shared.schema';
export const clients = sqliteTable('clients', {
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: text('name').notNull(),
type: text('type').notNull().default('business'),
status: text('status').notNull().default('active'),
website: text('website'),
notes: text('notes'),
...timestamps
});
+23
View File
@@ -0,0 +1,23 @@
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { clients } from './clients.schema';
import { timestamps } from './shared.schema';
export const contacts = sqliteTable(
'contacts',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
clientId: text('client_id')
.notNull()
.references(() => clients.id, { onDelete: 'restrict' }),
name: text('name').notNull(),
role: text('role'),
email: text('email'),
phone: text('phone'),
isPrimary: integer('is_primary', { mode: 'boolean' }).notNull().default(false),
notes: text('notes'),
...timestamps
},
(table) => [index('contacts_client_id_idx').on(table.clientId)]
);
+23
View File
@@ -0,0 +1,23 @@
import { index, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { clients } from './clients.schema';
import { timestamps } from './shared.schema';
export const contracts = sqliteTable(
'contracts',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
clientId: text('client_id')
.notNull()
.references(() => clients.id, { onDelete: 'restrict' }),
title: text('title').notNull(),
startDate: text('start_date').notNull(),
endDate: text('end_date'),
status: text('status').notNull().default('draft'),
valueGbp: real('value_gbp').notNull().default(0),
notes: text('notes'),
...timestamps
},
(table) => [index('contracts_client_id_idx').on(table.clientId)]
);
+10
View File
@@ -0,0 +1,10 @@
import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client';
import * as schema from './schema';
import { env } from '$env/dynamic/private';
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
const client = createClient({ url: env.DATABASE_URL });
export const db = drizzle(client, { schema });
+25
View File
@@ -0,0 +1,25 @@
import { index, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { clients } from './clients.schema';
import { timestamps } from './shared.schema';
export const invoices = sqliteTable(
'invoices',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
clientId: text('client_id')
.notNull()
.references(() => clients.id, { onDelete: 'restrict' }),
invoiceNumber: text('invoice_number').notNull().unique(),
issueDate: text('issue_date').notNull(),
dueDate: text('due_date').notNull(),
status: text('status').notNull().default('draft'),
subtotalGbp: real('subtotal_gbp').notNull().default(0),
taxGbp: real('tax_gbp').notNull().default(0),
totalGbp: real('total_gbp').notNull().default(0),
notes: text('notes'),
...timestamps
},
(table) => [index('invoices_client_id_idx').on(table.clientId)]
);
+19
View File
@@ -0,0 +1,19 @@
import { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { timestamps } from './shared.schema';
export const rooms = sqliteTable('rooms', {
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: text('name').notNull(),
type: text('type').notNull().default('meeting_room'),
sqFt: integer('sq_ft'),
internalPricePerHourGbp: real('internal_price_per_hour_gbp'),
externalPricePerHourGbp: real('external_price_per_hour_gbp'),
internalPricePerDayGbp: real('internal_price_per_day_gbp'),
externalPricePerDayGbp: real('external_price_per_day_gbp'),
maxAttendees: integer('max_attendees'),
workstations: integer('workstations'),
pricePerMonthGbp: real('price_per_month_gbp'),
...timestamps
});
+19
View File
@@ -0,0 +1,19 @@
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
export const task = sqliteTable('task', {
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
title: text('title').notNull(),
priority: integer('priority').notNull().default(1)
});
export * from './clients.schema';
export * from './addresses.schema';
export * from './contacts.schema';
export * from './rooms.schema';
export * from './services.schema';
export * from './bookings.schema';
export * from './invoices.schema';
export * from './contracts.schema';
export * from './auth.schema';
+14
View File
@@ -0,0 +1,14 @@
import { real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { timestamps } from './shared.schema';
export const services = sqliteTable('services', {
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: text('name').notNull(),
category: text('category'),
unit: text('unit').notNull().default('each'),
priceGbp: real('price_gbp').notNull().default(0),
description: text('description'),
...timestamps
});
+13
View File
@@ -0,0 +1,13 @@
import { sql } from 'drizzle-orm';
import { integer } from 'drizzle-orm/sqlite-core';
export const timestamps = {
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.$onUpdate(() => new Date())
.notNull(),
archivedAt: integer('archived_at', { mode: 'timestamp_ms' })
};