Initial commit

This commit is contained in:
michaeltieso
2025-12-01 14:49:17 +00:00
commit 3480888eaa
92 changed files with 16631 additions and 0 deletions

146
lib/db/index.ts Normal file
View File

@@ -0,0 +1,146 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import * as schema from './schema';
import path from 'path';
import fs from 'fs';
// Lazy initialization to avoid database access during build
let _db: ReturnType<typeof drizzle> | null = null;
let _sqlite: Database.Database | null = null;
function getDb() {
if (!_db) {
// Ensure data directories exist
const dataDir = path.join(process.cwd(), 'data');
const dbDir = path.join(dataDir, 'db');
const uploadsDir = path.join(dataDir, 'uploads');
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
// Database file path
const dbPath = path.join(dbDir, 'wishlist.db');
// Create SQLite database connection
_sqlite = new Database(dbPath);
_sqlite.pragma('journal_mode = WAL'); // Better concurrency
// Create Drizzle instance
_db = drizzle(_sqlite, { schema });
}
return _db;
}
// Export db as a getter
export const db = new Proxy({} as ReturnType<typeof drizzle>, {
get(target, prop) {
return getDb()[prop as keyof ReturnType<typeof drizzle>];
}
});
// Initialize database (create tables and seed if needed)
export async function initializeDatabase() {
try {
// Ensure database is initialized
const sqlite = _sqlite || getDb() && _sqlite;
if (!sqlite) throw new Error('Failed to initialize database');
// Create wishlists table
sqlite.exec(`
CREATE TABLE IF NOT EXISTS wishlists (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT,
preferences TEXT,
image_url TEXT,
notes TEXT,
is_public INTEGER DEFAULT 0 NOT NULL,
sort_order INTEGER DEFAULT 0 NOT NULL,
created_at INTEGER DEFAULT (unixepoch()) NOT NULL,
updated_at INTEGER DEFAULT (unixepoch()) NOT NULL
)
`);
// Create wishlist_items table
sqlite.exec(`
CREATE TABLE IF NOT EXISTS wishlist_items (
id TEXT PRIMARY KEY NOT NULL,
wishlist_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
price REAL,
currency TEXT DEFAULT 'USD' NOT NULL,
quantity INTEGER DEFAULT 1 NOT NULL,
images TEXT,
purchase_urls TEXT,
notes TEXT,
is_archived INTEGER DEFAULT 0 NOT NULL,
claimed_by_name TEXT,
claimed_by_note TEXT,
claimed_by_token TEXT UNIQUE,
claimed_at INTEGER,
is_purchased INTEGER DEFAULT 0 NOT NULL,
sort_order INTEGER DEFAULT 0 NOT NULL,
created_at INTEGER DEFAULT (unixepoch()) NOT NULL,
updated_at INTEGER DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (wishlist_id) REFERENCES wishlists(id) ON DELETE CASCADE
)
`);
// Create settings table
sqlite.exec(`
CREATE TABLE IF NOT EXISTS settings (
id TEXT PRIMARY KEY NOT NULL,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
updated_at INTEGER DEFAULT (unixepoch()) NOT NULL
)
`);
// Run migrations for existing databases
try {
const columns = sqlite.pragma('table_info(wishlists)') as Array<{ name: string }>;
// Add image_url column if it doesn't exist
const hasImageUrl = columns.some((col) => col.name === 'image_url');
if (!hasImageUrl) {
sqlite.exec('ALTER TABLE wishlists ADD COLUMN image_url TEXT');
console.log('✅ Added image_url column to wishlists table');
}
// Add sort_order column if it doesn't exist
const hasSortOrder = columns.some((col) => col.name === 'sort_order');
if (!hasSortOrder) {
sqlite.exec('ALTER TABLE wishlists ADD COLUMN sort_order INTEGER DEFAULT 0 NOT NULL');
console.log('✅ Added sort_order column to wishlists table');
}
// Add preferences column if it doesn't exist
const hasPreferences = columns.some((col) => col.name === 'preferences');
if (!hasPreferences) {
sqlite.exec('ALTER TABLE wishlists ADD COLUMN preferences TEXT');
console.log('✅ Added preferences column to wishlists table');
}
} catch (migrationError) {
console.log('Migration already applied or not needed');
}
// Auto-seed database if empty
const { seedDatabase } = await import('./seed');
await seedDatabase();
return true;
} catch (error) {
console.error('❌ Database initialization failed:', error);
throw error;
}
}
// Export schema for use in other files
export * from './schema';

68
lib/db/schema.ts Normal file
View File

@@ -0,0 +1,68 @@
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
import { createId } from '@paralleldrive/cuid2';
import { sql } from 'drizzle-orm';
/**
* Database schema for family wishlist app
*
* Simplified for self-hosted family use:
* - No Settings table (use env vars instead)
* - Image URLs only (no upload handling in MVP)
* - Simplified purchase URLs (no usage tracking)
*/
// Wishlists table
export const wishlists = sqliteTable('wishlists', {
id: text('id').primaryKey().$defaultFn(() => createId()),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
description: text('description'),
preferences: text('preferences'), // General interests/likes section
imageUrl: text('image_url'),
isPublic: integer('is_public', { mode: 'boolean' }).notNull().default(false),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
});
// Wishlist items table
export const wishlistItems = sqliteTable('wishlist_items', {
id: text('id').primaryKey().$defaultFn(() => createId()),
wishlistId: text('wishlist_id').notNull().references(() => wishlists.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
price: real('price'),
currency: text('currency').notNull().default('USD'),
quantity: integer('quantity').notNull().default(1),
imageUrl: text('images'), // Stored as 'images' in DB but exposed as imageUrl
purchaseUrls: text('purchase_urls', { mode: 'json' }).$type<Array<{
label: string;
url: string;
}>>(),
isArchived: integer('is_archived', { mode: 'boolean' }).notNull().default(false),
// Claim information
claimedByName: text('claimed_by_name'),
claimedByNote: text('claimed_by_note'),
claimedByToken: text('claimed_by_token').unique(),
claimedAt: integer('claimed_at', { mode: 'timestamp' }),
isPurchased: integer('is_purchased', { mode: 'boolean' }).notNull().default(false),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
});
// Settings table
export const settings = sqliteTable('settings', {
id: text('id').primaryKey().$defaultFn(() => createId()),
key: text('key').notNull().unique(),
value: text('value').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
});
// Type exports
export type Wishlist = typeof wishlists.$inferSelect;
export type WishlistItem = typeof wishlistItems.$inferSelect;
export type Setting = typeof settings.$inferSelect;

303
lib/db/seed.ts Normal file
View File

@@ -0,0 +1,303 @@
import { db } from './index';
import { wishlists, wishlistItems, settings } from './schema';
import { eq } from 'drizzle-orm';
/**
* Seed the database with sample data
* This creates example wishlists and items to help users get started
*/
export async function seedDatabase() {
try {
// Check if data already exists
const existingWishlists = await db.select().from(wishlists).limit(1);
if (existingWishlists.length > 0) {
return;
}
// Seed settings
await db.insert(settings).values([
{
key: 'siteTitle',
value: 'Wishlist',
},
{
key: 'homepageSubtext',
value: 'Hello! Thank you so much for thinking of us! When you purchase something from our list, just click "Claim" to mark it. We promise not to peek at what\'s been claimed. This works on the honor system, so please only claim items you\'ve actually bought. We appreciate you!',
},
]);
// Create sample wishlists
const [dadWishlist] = await db.insert(wishlists).values({
name: "Dad's Wishlist",
slug: 'dads-wishlist',
description: 'Birthday and holiday gift ideas',
imageUrl: '/images/wishlists/dad.png',
isPublic: true,
}).returning();
const [momWishlist] = await db.insert(wishlists).values({
name: "Mom's Wishlist",
slug: 'moms-wishlist',
description: 'Things I would love!',
imageUrl: '/images/wishlists/mom.png',
isPublic: true,
}).returning();
const [childWishlist] = await db.insert(wishlists).values({
name: "Child's Wishlist",
slug: 'childs-wishlist',
description: 'Toys, books, and fun things!',
imageUrl: '/images/wishlists/child.png',
isPublic: true,
}).returning();
// Dad's wishlist items
await db.insert(wishlistItems).values([
{
wishlistId: dadWishlist.id,
name: 'The Book of Unusual Knowledge: Big Book of Fascinating Facts & Information',
description: 'I love learning random trivia and this looks like the perfect coffee table book to flip through when I have a few minutes.',
price: 10.00,
currency: 'USD',
quantity: 1,
sortOrder: 0,
imageUrl: '/images/items/dad1.jpg',
purchaseUrls: [
{ label: 'Amazon - $10.00', url: 'https://www.amazon.com/Unusual-Knowledge-Editors-Publications-International/dp/1450845800/ref=sr_1_6' },
],
},
{
wishlistId: dadWishlist.id,
name: 'Anker PowerCore 10000 Portable Charger',
description: 'My phone always dies when I need it most. This would be perfect for keeping in my bag for emergencies.',
price: 26.00,
currency: 'USD',
quantity: 1,
sortOrder: 1,
imageUrl: '/images/items/dad2.jpg',
purchaseUrls: [
{ label: 'Amazon - $26.00', url: 'https://www.amazon.com/Anker-PowerCore-Ultra-Compact-High-Speed-Technology/dp/B0194WDVHI' },
{ label: 'Micro Center - $29.99', url: 'https://www.microcenter.com/product/686695/anker-10k-225w-power-bank' },
{ label: 'Staples - $34.99', url: 'https://www.staples.com/anker-powercore-power-bank-10000mah-22-5w-portable-charger-with-usb-c-lanyard-cable-black-a1388h11-1/product_24617552' },
],
},
{
wishlistId: dadWishlist.id,
name: 'Yeti Rambler 20 oz Tumbler',
description: 'I want something that keeps my coffee hot during my entire commute. This looks like it would be perfect.',
price: 30.00,
currency: 'USD',
quantity: 1,
sortOrder: 2,
imageUrl: '/images/items/dad3.webp',
purchaseUrls: [
{ label: 'Amazon - $30.00', url: 'https://www.amazon.com/YETI-Rambler-Tumbler-Vacuum-Insulated/dp/B073WKWYJJ' },
{ label: 'YETI - $30.00', url: 'https://www.yeti.com/drinkware/tumblers/rambler-20-oz-tumbler.html' },
{ label: 'REI - $30.00', url: 'https://www.rei.com/product/113804/yeti-rambler-20-fl-oz-tumbler' },
],
},
{
wishlistId: dadWishlist.id,
name: 'Tile Mate Bluetooth Tracker 4-Pack',
description: 'I lose my keys at least twice a week. These would save me so much time and frustration.',
price: 70.00,
currency: 'USD',
quantity: 1,
sortOrder: 3,
imageUrl: '/images/items/dad4.webp',
purchaseUrls: [
{ label: 'Amazon - $70.00', url: 'https://www.amazon.com/Tile-Mate-4-Pack-Bluetooth-Finder/dp/B09B2WLRWH' },
{ label: 'Best Buy - $74.99', url: 'https://www.bestbuy.com/site/tile-mate-bluetooth-tracker-4-pack/6451674.p' },
{ label: 'Target - $69.99', url: 'https://www.target.com/p/tile-mate-bluetooth-tracker-4pk/-/A-82215989' },
],
},
{
wishlistId: dadWishlist.id,
name: 'Leatherman Wave Plus Multi-Tool',
description: 'I always need a tool when I don\'t have one. This has everything in one compact package.',
price: 120.00,
currency: 'USD',
quantity: 1,
sortOrder: 4,
imageUrl: '/images/items/dad5.jpg',
purchaseUrls: [
{ label: 'Amazon - $120.00', url: 'https://www.amazon.com/LEATHERMAN-Wave-Multitool-Black-Molle/dp/B07DD69QN3' },
{ label: 'REI - $124.95', url: 'https://www.rei.com/product/766953/leatherman-wave-plus-multitool' },
{ label: 'Home Depot - $119.99', url: 'https://www.homedepot.com/p/LEATHERMAN-Wave-Plus-Multi-Tool-832524/305408085' },
],
},
]);
// Mom's wishlist items
await db.insert(wishlistItems).values([
{
wishlistId: momWishlist.id,
name: 'Kindle Paperwhite (16 GB)',
description: 'I love reading before bed and this would be perfect for not disturbing anyone with a lamp.',
price: 140.00,
currency: 'USD',
quantity: 1,
sortOrder: 0,
imageUrl: '/images/items/mom1.jpg',
purchaseUrls: [
{ label: 'Amazon - $139.99', url: 'https://www.amazon.com/Kindle-Paperwhite-16-GB/dp/B08N3J8GTX' },
{ label: 'Best Buy - $139.99', url: 'https://www.bestbuy.com/site/amazon-kindle-paperwhite-16gb/6522383.p' },
{ label: 'Target - $139.99', url: 'https://www.target.com/p/kindle-paperwhite/-/A-84491392' },
],
},
{
wishlistId: momWishlist.id,
name: 'Lululemon Align High-Rise Pant 25"',
description: 'I need comfortable leggings for yoga class and these are supposed to be amazing.',
price: 98.00,
currency: 'USD',
quantity: 1,
sortOrder: 1,
imageUrl: '/images/items/mom2.webp',
purchaseUrls: [
{ label: 'Lululemon - $98.00', url: 'https://shop.lululemon.com/p/women-pants/Align-Pant-2' },
{ label: 'Amazon - $98.00', url: 'https://www.amazon.com/stores/page/lululemon' },
],
},
{
wishlistId: momWishlist.id,
name: 'Hydro Flask 32 oz Wide Mouth Water Bottle',
description: 'I want to drink more water throughout the day and this keeps drinks cold for 24 hours.',
price: 45.00,
currency: 'USD',
quantity: 1,
sortOrder: 2,
imageUrl: '/images/items/mom3.jpg',
purchaseUrls: [
{ label: 'Amazon - $44.95', url: 'https://www.amazon.com/Hydro-Flask-Water-Bottle-Stainless/dp/B01ACVS6RE' },
{ label: 'REI - $44.95', url: 'https://www.rei.com/product/889468/hydro-flask-wide-mouth-water-bottle-32-fl-oz' },
{ label: 'Dick\'s Sporting Goods - $44.99', url: 'https://www.dickssportinggoods.com/p/hydro-flask-32-oz-wide-mouth-bottle/16hflu32zwd' },
],
},
{
wishlistId: momWishlist.id,
name: 'Revlon One-Step Volumizer Hair Dryer',
description: 'My hair takes forever to style and this looks like it would save me so much time in the morning.',
price: 40.00,
currency: 'USD',
quantity: 1,
sortOrder: 3,
imageUrl: '/images/items/mom4.jpg',
purchaseUrls: [
{ label: 'Amazon - $39.99', url: 'https://www.amazon.com/Revlon-One-Step-Dryer-Volumizer/dp/B01LSUQSB0' },
{ label: 'Target - $39.99', url: 'https://www.target.com/p/revlon-one-step-volumizer/-/A-53003976' },
{ label: 'Walmart - $39.96', url: 'https://www.walmart.com/ip/Revlon-One-Step-Hair-Dryer-Volumizer/55689116' },
],
},
{
wishlistId: momWishlist.id,
name: 'Apple AirPods Pro (2nd Generation)',
description: 'I need better earbuds for my workouts and calls. These have noise cancellation which would be perfect for the gym.',
price: 249.00,
currency: 'USD',
quantity: 1,
sortOrder: 4,
imageUrl: '/images/items/mom5.jpg',
purchaseUrls: [
{ label: 'Amazon - $249.00', url: 'https://www.amazon.com/Apple-Generation-Cancelling-Transparency-Personalized/dp/B0CHWRXH8B' },
{ label: 'Best Buy - $249.99', url: 'https://www.bestbuy.com/site/apple-airpods-pro-2nd-generation/6447382.p' },
{ label: 'Target - $249.99', url: 'https://www.target.com/p/apple-airpods-pro-2nd-generation/-/A-85978622' },
],
},
]);
// Child's wishlist items
await db.insert(wishlistItems).values([
{
wishlistId: childWishlist.id,
name: 'LEGO Classic Medium Creative Brick Box',
description: 'I love building things and making my own creations! This has so many pieces to build anything I can imagine.',
price: 30.00,
currency: 'USD',
quantity: 1,
sortOrder: 0,
imageUrl: '/images/items/child1.jpg',
purchaseUrls: [
{ label: 'Amazon - $29.99', url: 'https://www.amazon.com/LEGO-Classic-Medium-Creative-Construction/dp/B00NHQFA3Y' },
{ label: 'Target - $29.99', url: 'https://www.target.com/p/lego-classic-medium-creative-brick-box/-/A-14182781' },
{ label: 'Walmart - $29.97', url: 'https://www.walmart.com/ip/LEGO-Classic-Medium-Creative-Brick-Box-10696/34611691' },
],
},
{
wishlistId: childWishlist.id,
name: 'Crayola Ultimate Crayon Collection',
description: 'I need more colors for my drawings! This has 152 crayons with all the colors I could ever want.',
price: 20.00,
currency: 'USD',
quantity: 1,
sortOrder: 1,
imageUrl: '/images/items/child2.jpg',
purchaseUrls: [
{ label: 'Amazon - $19.99', url: 'https://www.amazon.com/Crayola-Ultimate-Crayon-Collection-Colors/dp/B00LH32JDE' },
{ label: 'Target - $19.99', url: 'https://www.target.com/p/crayola-ultimate-crayon-collection/-/A-14676404' },
{ label: 'Walmart - $19.94', url: 'https://www.walmart.com/ip/Crayola-Ultimate-Crayon-Collection-152-Colors/26228172' },
],
},
{
wishlistId: childWishlist.id,
name: 'National Geographic Kids World Atlas',
description: 'I want to learn about all the different countries and places in the world. This book has cool maps and pictures!',
price: 25.00,
currency: 'USD',
quantity: 1,
sortOrder: 2,
imageUrl: '/images/items/child3.jpg',
purchaseUrls: [
{ label: 'Amazon - $24.99', url: 'https://www.amazon.com/National-Geographic-Kids-World-Atlas/dp/1426375905' },
{ label: 'Barnes & Noble - $24.99', url: 'https://www.barnesandnoble.com/w/national-geographic-kids-world-atlas/1141356542' },
{ label: 'Target - $24.99', url: 'https://www.target.com/p/national-geographic-kids-world-atlas/-/A-87621944' },
],
},
{
wishlistId: childWishlist.id,
name: 'Razor A Kick Scooter',
description: 'All my friends have scooters and I really want one too so I can ride with them to the park!',
price: 40.00,
currency: 'USD',
quantity: 1,
sortOrder: 3,
imageUrl: '/images/items/child4.jpg',
purchaseUrls: [
{ label: 'Amazon - $39.99', url: 'https://www.amazon.com/Razor-Kick-Scooter-FFP/dp/B00005NBON' },
{ label: 'Target - $39.99', url: 'https://www.target.com/p/razor-a-kick-scooter/-/A-10853734' },
{ label: 'Walmart - $39.88', url: 'https://www.walmart.com/ip/Razor-A-Kick-Scooter/10314287' },
],
},
{
wishlistId: childWishlist.id,
name: 'Melissa & Doug Wooden Building Blocks Set',
description: 'I like making towers and houses with blocks. These wooden ones look really cool and sturdy!',
price: 35.00,
currency: 'USD',
quantity: 1,
sortOrder: 4,
imageUrl: '/images/items/child5.jpg',
purchaseUrls: [
{ label: 'Amazon - $34.99', url: 'https://www.amazon.com/Melissa-Doug-Standard-Wooden-Building/dp/B00005RF5G' },
{ label: 'Target - $34.99', url: 'https://www.target.com/p/melissa-doug-wooden-building-blocks-set/-/A-10917067' },
{ label: 'Walmart - $34.97', url: 'https://www.walmart.com/ip/Melissa-Doug-Wooden-Building-Blocks-Set/5024533' },
],
},
]);
return true;
} catch (error) {
console.error('❌ Database seeding failed:', error);
throw error;
}
}
// Run seed if called directly
if (require.main === module) {
seedDatabase()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
}