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
+9
View File
@@ -0,0 +1,9 @@
# Drizzle
DATABASE_URL=file:local.db
ORIGIN=""
# Better Auth
# For production use 32 characters and generated with high entropy
# https://www.better-auth.com/docs/installation
BETTER_AUTH_SECRET=""
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'drizzle-kit';
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
export default defineConfig({
schema: './src/lib/server/db/schema.ts',
dialect: 'sqlite',
dbCredentials: { url: process.env.DATABASE_URL },
verbose: true,
strict: true
});
+188
View File
@@ -0,0 +1,188 @@
CREATE TABLE `task` (
`id` text PRIMARY KEY NOT NULL,
`title` text NOT NULL,
`priority` integer DEFAULT 1 NOT NULL
);
--> statement-breakpoint
CREATE TABLE `clients` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`type` text DEFAULT 'business' NOT NULL,
`status` text DEFAULT 'active' NOT NULL,
`email` text,
`phone` text,
`website` text,
`notes` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`archived_at` integer
);
--> statement-breakpoint
CREATE TABLE `addresses` (
`id` text PRIMARY KEY NOT NULL,
`client_id` text NOT NULL,
`label` text NOT NULL,
`line_1` text NOT NULL,
`line_2` text,
`city` text NOT NULL,
`region` text,
`postcode` text NOT NULL,
`country` text DEFAULT 'United Kingdom' NOT NULL,
`is_primary` integer DEFAULT false NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`archived_at` integer,
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE restrict
);
--> statement-breakpoint
CREATE INDEX `addresses_client_id_idx` ON `addresses` (`client_id`);--> statement-breakpoint
CREATE TABLE `contacts` (
`id` text PRIMARY KEY NOT NULL,
`client_id` text NOT NULL,
`name` text NOT NULL,
`role` text,
`email` text,
`phone` text,
`is_primary` integer DEFAULT false NOT NULL,
`notes` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`archived_at` integer,
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE restrict
);
--> statement-breakpoint
CREATE INDEX `contacts_client_id_idx` ON `contacts` (`client_id`);--> statement-breakpoint
CREATE TABLE `rooms` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`type` text DEFAULT 'room' NOT NULL,
`capacity` integer DEFAULT 1 NOT NULL,
`location` text,
`status` text DEFAULT 'available' NOT NULL,
`hourly_rate_cents` integer DEFAULT 0 NOT NULL,
`notes` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`archived_at` integer
);
--> statement-breakpoint
CREATE TABLE `services` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`category` text,
`unit` text DEFAULT 'each' NOT NULL,
`price_cents` integer DEFAULT 0 NOT NULL,
`description` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`archived_at` integer
);
--> statement-breakpoint
CREATE TABLE `bookings` (
`id` text PRIMARY KEY NOT NULL,
`client_id` text NOT NULL,
`room_id` text NOT NULL,
`service_id` text,
`starts_at` text NOT NULL,
`ends_at` text NOT NULL,
`status` text DEFAULT 'booked' NOT NULL,
`notes` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`archived_at` integer,
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE restrict,
FOREIGN KEY (`room_id`) REFERENCES `rooms`(`id`) ON UPDATE no action ON DELETE restrict,
FOREIGN KEY (`service_id`) REFERENCES `services`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `bookings_client_id_idx` ON `bookings` (`client_id`);--> statement-breakpoint
CREATE INDEX `bookings_room_id_idx` ON `bookings` (`room_id`);--> statement-breakpoint
CREATE INDEX `bookings_service_id_idx` ON `bookings` (`service_id`);--> statement-breakpoint
CREATE TABLE `invoices` (
`id` text PRIMARY KEY NOT NULL,
`client_id` text NOT NULL,
`invoice_number` text NOT NULL,
`issue_date` text NOT NULL,
`due_date` text NOT NULL,
`status` text DEFAULT 'draft' NOT NULL,
`subtotal_cents` integer DEFAULT 0 NOT NULL,
`tax_cents` integer DEFAULT 0 NOT NULL,
`total_cents` integer DEFAULT 0 NOT NULL,
`notes` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`archived_at` integer,
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE restrict
);
--> statement-breakpoint
CREATE UNIQUE INDEX `invoices_invoice_number_unique` ON `invoices` (`invoice_number`);--> statement-breakpoint
CREATE INDEX `invoices_client_id_idx` ON `invoices` (`client_id`);--> statement-breakpoint
CREATE TABLE `contracts` (
`id` text PRIMARY KEY NOT NULL,
`client_id` text NOT NULL,
`title` text NOT NULL,
`start_date` text NOT NULL,
`end_date` text,
`status` text DEFAULT 'draft' NOT NULL,
`value_cents` integer DEFAULT 0 NOT NULL,
`notes` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`archived_at` integer,
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE restrict
);
--> statement-breakpoint
CREATE INDEX `contracts_client_id_idx` ON `contracts` (`client_id`);--> statement-breakpoint
CREATE TABLE `account` (
`id` text PRIMARY KEY NOT NULL,
`account_id` text NOT NULL,
`provider_id` text NOT NULL,
`user_id` text NOT NULL,
`access_token` text,
`refresh_token` text,
`id_token` text,
`access_token_expires_at` integer,
`refresh_token_expires_at` integer,
`scope` text,
`password` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `account_userId_idx` ON `account` (`user_id`);--> statement-breakpoint
CREATE TABLE `session` (
`id` text PRIMARY KEY NOT NULL,
`expires_at` integer NOT NULL,
`token` text NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer NOT NULL,
`ip_address` text,
`user_agent` text,
`user_id` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint
CREATE INDEX `session_userId_idx` ON `session` (`user_id`);--> statement-breakpoint
CREATE TABLE `user` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`email` text NOT NULL,
`email_verified` integer DEFAULT false NOT NULL,
`image` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
CREATE TABLE `verification` (
`id` text PRIMARY KEY NOT NULL,
`identifier` text NOT NULL,
`value` text NOT NULL,
`expires_at` integer NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
);
--> statement-breakpoint
CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE `clients` DROP COLUMN `email`;--> statement-breakpoint
ALTER TABLE `clients` DROP COLUMN `phone`;
+24
View File
@@ -0,0 +1,24 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_rooms` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`type` text DEFAULT 'meeting_room' NOT NULL,
`sq_ft` integer,
`internal_price_per_hour_cents` integer,
`external_price_per_hour_cents` integer,
`internal_price_per_day_cents` integer,
`external_price_per_day_cents` integer,
`internal_price_per_half_day_cents` integer,
`external_price_per_half_day_cents` integer,
`max_attendees` integer,
`workstations` integer,
`price_per_month_cents` integer,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`archived_at` integer
);
--> statement-breakpoint
INSERT INTO `__new_rooms`("id", "name", "type", "sq_ft", "internal_price_per_hour_cents", "external_price_per_hour_cents", "internal_price_per_day_cents", "external_price_per_day_cents", "internal_price_per_half_day_cents", "external_price_per_half_day_cents", "max_attendees", "workstations", "price_per_month_cents", "created_at", "updated_at", "archived_at") SELECT "id", "name", CASE WHEN "type" = 'office' THEN 'private_office' ELSE 'meeting_room' END, NULL, "hourly_rate_cents", "hourly_rate_cents", NULL, NULL, NULL, NULL, "capacity", NULL, NULL, "created_at", "updated_at", "archived_at" FROM `rooms`;--> statement-breakpoint
DROP TABLE `rooms`;--> statement-breakpoint
ALTER TABLE `__new_rooms` RENAME TO `rooms`;--> statement-breakpoint
PRAGMA foreign_keys=ON;
+80
View File
@@ -0,0 +1,80 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_rooms` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`type` text DEFAULT 'meeting_room' NOT NULL,
`sq_ft` integer,
`internal_price_per_hour_gbp` real,
`external_price_per_hour_gbp` real,
`internal_price_per_day_gbp` real,
`external_price_per_day_gbp` real,
`internal_price_per_half_day_gbp` real,
`external_price_per_half_day_gbp` real,
`max_attendees` integer,
`workstations` integer,
`price_per_month_gbp` real,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`archived_at` integer
);
--> statement-breakpoint
INSERT INTO `__new_rooms`("id", "name", "type", "sq_ft", "internal_price_per_hour_gbp", "external_price_per_hour_gbp", "internal_price_per_day_gbp", "external_price_per_day_gbp", "internal_price_per_half_day_gbp", "external_price_per_half_day_gbp", "max_attendees", "workstations", "price_per_month_gbp", "created_at", "updated_at", "archived_at") SELECT "id", "name", "type", "sq_ft", "internal_price_per_hour_cents" / 100.0, "external_price_per_hour_cents" / 100.0, "internal_price_per_day_cents" / 100.0, "external_price_per_day_cents" / 100.0, "internal_price_per_half_day_cents" / 100.0, "external_price_per_half_day_cents" / 100.0, "max_attendees", "workstations", "price_per_month_cents" / 100.0, "created_at", "updated_at", "archived_at" FROM `rooms`;--> statement-breakpoint
DROP TABLE `rooms`;--> statement-breakpoint
ALTER TABLE `__new_rooms` RENAME TO `rooms`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE TABLE `__new_services` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`category` text,
`unit` text DEFAULT 'each' NOT NULL,
`price_gbp` real DEFAULT 0 NOT NULL,
`description` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`archived_at` integer
);
--> statement-breakpoint
INSERT INTO `__new_services`("id", "name", "category", "unit", "price_gbp", "description", "created_at", "updated_at", "archived_at") SELECT "id", "name", "category", "unit", "price_cents" / 100.0, "description", "created_at", "updated_at", "archived_at" FROM `services`;--> statement-breakpoint
DROP TABLE `services`;--> statement-breakpoint
ALTER TABLE `__new_services` RENAME TO `services`;--> statement-breakpoint
CREATE TABLE `__new_invoices` (
`id` text PRIMARY KEY NOT NULL,
`client_id` text NOT NULL,
`invoice_number` text NOT NULL,
`issue_date` text NOT NULL,
`due_date` text NOT NULL,
`status` text DEFAULT 'draft' NOT NULL,
`subtotal_gbp` real DEFAULT 0 NOT NULL,
`tax_gbp` real DEFAULT 0 NOT NULL,
`total_gbp` real DEFAULT 0 NOT NULL,
`notes` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`archived_at` integer,
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE restrict
);
--> statement-breakpoint
INSERT INTO `__new_invoices`("id", "client_id", "invoice_number", "issue_date", "due_date", "status", "subtotal_gbp", "tax_gbp", "total_gbp", "notes", "created_at", "updated_at", "archived_at") SELECT "id", "client_id", "invoice_number", "issue_date", "due_date", "status", "subtotal_cents" / 100.0, "tax_cents" / 100.0, "total_cents" / 100.0, "notes", "created_at", "updated_at", "archived_at" FROM `invoices`;--> statement-breakpoint
DROP TABLE `invoices`;--> statement-breakpoint
ALTER TABLE `__new_invoices` RENAME TO `invoices`;--> statement-breakpoint
CREATE UNIQUE INDEX `invoices_invoice_number_unique` ON `invoices` (`invoice_number`);--> statement-breakpoint
CREATE INDEX `invoices_client_id_idx` ON `invoices` (`client_id`);--> statement-breakpoint
CREATE TABLE `__new_contracts` (
`id` text PRIMARY KEY NOT NULL,
`client_id` text NOT NULL,
`title` text NOT NULL,
`start_date` text NOT NULL,
`end_date` text,
`status` text DEFAULT 'draft' NOT NULL,
`value_gbp` real DEFAULT 0 NOT NULL,
`notes` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`archived_at` integer,
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE restrict
);
--> statement-breakpoint
INSERT INTO `__new_contracts`("id", "client_id", "title", "start_date", "end_date", "status", "value_gbp", "notes", "created_at", "updated_at", "archived_at") SELECT "id", "client_id", "title", "start_date", "end_date", "status", "value_cents" / 100.0, "notes", "created_at", "updated_at", "archived_at" FROM `contracts`;--> statement-breakpoint
DROP TABLE `contracts`;--> statement-breakpoint
ALTER TABLE `__new_contracts` RENAME TO `contracts`;--> statement-breakpoint
CREATE INDEX `contracts_client_id_idx` ON `contracts` (`client_id`);
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE `rooms` DROP COLUMN `internal_price_per_half_day_gbp`;--> statement-breakpoint
ALTER TABLE `rooms` DROP COLUMN `external_price_per_half_day_gbp`;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1779571091471,
"tag": "0000_wild_guardian",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1780516359556,
"tag": "0001_amusing_maximus",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1780608782775,
"tag": "0002_volatile_gressill",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1780609384877,
"tag": "0003_tan_george_stacy",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1780609661307,
"tag": "0004_orange_ultimatum",
"breakpoints": true
}
]
}
+90
View File
@@ -0,0 +1,90 @@
import { createClient } from '@libsql/client';
import { hashPassword } from 'better-auth/crypto';
import { readFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
function readDotEnv() {
try {
const entries = readFileSync('.env', 'utf8')
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'))
.map((line) => {
const index = line.indexOf('=');
const key = line.slice(0, index);
const value = line.slice(index + 1).replace(/^"|"$/g, '');
return [key, value];
});
for (const [key, value] of entries) {
process.env[key] ??= value;
}
} catch {
// .env is optional in deployed or scripted environments.
}
}
readDotEnv();
const databaseUrl = process.env.DATABASE_URL;
const email = process.env.ADMIN_EMAIL?.trim().toLowerCase();
const password = process.env.ADMIN_PASSWORD;
const name = process.env.ADMIN_NAME?.trim() || 'Administrator';
const overwrite = process.env.ADMIN_OVERWRITE === '1';
if (!databaseUrl) {
console.error('DATABASE_URL is required.');
process.exit(1);
}
if (!email || !password) {
console.error('ADMIN_EMAIL and ADMIN_PASSWORD are required.');
console.error('Example: ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=change-me pnpm admin:create');
process.exit(1);
}
const client = createClient({ url: databaseUrl });
const now = Date.now();
const existing = await client.execute({
sql: 'select id from user where email = ? limit 1',
args: [email]
});
if (existing.rows.length > 0 && !overwrite) {
console.error(`A user with email ${email} already exists. Set ADMIN_OVERWRITE=1 to update the password.`);
process.exit(1);
}
const userId = existing.rows[0]?.id?.toString() ?? randomUUID();
const passwordHash = await hashPassword(password);
if (existing.rows.length === 0) {
await client.execute({
sql: 'insert into user (id, name, email, email_verified, image, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?)',
args: [userId, name, email, 1, null, now, now]
});
} else {
await client.execute({
sql: 'update user set name = ?, email_verified = ?, updated_at = ? where id = ?',
args: [name, 1, now, userId]
});
}
const account = await client.execute({
sql: 'select id from account where user_id = ? and provider_id = ? limit 1',
args: [userId, 'credential']
});
if (account.rows.length === 0) {
await client.execute({
sql: 'insert into account (id, account_id, provider_id, user_id, password, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?)',
args: [randomUUID(), userId, 'credential', userId, passwordHash, now, now]
});
} else {
await client.execute({
sql: 'update account set password = ?, updated_at = ? where id = ?',
args: [passwordHash, now, account.rows[0].id]
});
}
console.log(`Admin user ready: ${email}`);
+23
View File
@@ -0,0 +1,23 @@
import type { Handle } from '@sveltejs/kit';
import { building } from '$app/environment';
import { auth } from '$lib/server/auth';
import { svelteKitHandler } from 'better-auth/svelte-kit';
import { redirect } from '@sveltejs/kit';
const handleBetterAuth: Handle = async ({ event, resolve }) => {
const session = await auth.api.getSession({ headers: event.request.headers });
if (session) {
event.locals.session = session.session;
event.locals.user = session.user;
}
if (event.url.pathname.startsWith('/dashboard') && !event.locals.user) {
const redirectTo = event.url.pathname + event.url.search;
redirect(303, `/login?redirectTo=${encodeURIComponent(redirectTo)}`);
}
return svelteKitHandler({ event, resolve, auth, building });
};
export const handle: Handle = handleBetterAuth;
+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' })
};
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
import './layout.css';
import favicon from '$lib/assets/favicon.svg';
import { Toaster } from '$lib/components/ui/sonner/index.js';
let { children } = $props();
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
{@render children()}
<Toaster position="top-center" richColors />
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = () => {
redirect(303, '/dashboard');
};
+2
View File
@@ -0,0 +1,2 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
+64
View File
@@ -0,0 +1,64 @@
import { redirect } from '@sveltejs/kit';
import { APIError } from 'better-auth/api';
import { message, superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import { auth } from '$lib/server/auth';
import { loginSchema } from '$lib/schemas/auth';
import type { Actions, PageServerLoad } from './$types';
function getSafeRedirect(url: URL) {
const redirectTo = url.searchParams.get('redirectTo');
if (!redirectTo || !redirectTo.startsWith('/') || redirectTo.startsWith('//')) {
return '/dashboard';
}
return redirectTo;
}
export const load: PageServerLoad = async ({ locals, url }) => {
if (locals.user) {
redirect(303, getSafeRedirect(url));
}
return {
form: await superValidate(
{
email: url.searchParams.get('email') ?? '',
password: ''
},
zod4(loginSchema),
{ id: 'login' }
)
};
};
export const actions: Actions = {
default: async (event) => {
const form = await superValidate(event, zod4(loginSchema), { id: 'login' });
if (!form.valid) {
return message(form, 'Check the highlighted fields.', { status: 400 });
}
try {
await auth.api.signInEmail({
body: {
email: form.data.email,
password: form.data.password,
callbackURL: getSafeRedirect(event.url)
}
});
} catch (error) {
if (error instanceof APIError) {
return message(form, error.message || 'The email or password you entered is incorrect.', {
status: 400
});
}
return message(form, 'Something went wrong while signing you in.', { status: 500 });
}
redirect(303, getSafeRedirect(event.url));
}
};
+12
View File
@@ -0,0 +1,12 @@
<script lang="ts">
import LoginForm from '$lib/components/login-form.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<div class="flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10">
<div class="w-full max-w-sm">
<LoginForm form={data.form} />
</div>
</div>