feat: add versioned migrations and kysely query layer

This commit is contained in:
Bastian Wagner
2026-08-17 14:33:28 +02:00
parent acfbb3ab22
commit ec95a8e527
17 changed files with 513 additions and 50 deletions

View File

@@ -0,0 +1,16 @@
exports.up = (pgm) => {
pgm.createExtension('pgcrypto', { ifNotExists: true });
pgm.createTable('users', {
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
external_subject_id: { type: 'text', notNull: true },
display_name: { type: 'text', notNull: true },
email: { type: 'text', notNull: true },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
pgm.addConstraint('users', 'users_external_subject_id_key', 'UNIQUE(external_subject_id)');
};
exports.down = (pgm) => {
pgm.dropTable('users');
};

View File

@@ -0,0 +1,24 @@
exports.up = (pgm) => {
pgm.createTable('user_preferences', {
user_id: {
type: 'uuid',
primaryKey: true,
notNull: true,
references: 'users(id)',
onDelete: 'CASCADE',
},
preferred_pace: { type: 'text' },
preferred_budget_level: { type: 'text' },
max_walking_distance_km: { type: 'numeric' },
preferred_start_time: { type: 'text' },
child_friendly_preferred: { type: 'boolean', notNull: true, default: false },
interests: { type: 'text[]', notNull: true, default: '{}' },
notes: { type: 'text' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
};
exports.down = (pgm) => {
pgm.dropTable('user_preferences');
};

View File

@@ -0,0 +1,26 @@
exports.up = (pgm) => {
pgm.createTable('trips', {
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
name: { type: 'text', notNull: true },
description: { type: 'text' },
owner_id: { type: 'uuid', notNull: true, references: 'users(id)' },
start_date: { type: 'date' },
end_date: { type: 'date' },
status: { type: 'text', notNull: true, default: 'DRAFT' },
planning_stage: { type: 'text' },
currency: { type: 'text', notNull: true, default: 'EUR' },
version: { type: 'integer', notNull: true, default: 1 },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
pgm.addConstraint(
'trips',
'trips_status_check',
"CHECK (status IN ('DRAFT','PLANNING','BOOKING','UPCOMING','ACTIVE','COMPLETED','ARCHIVED'))",
);
pgm.createIndex('trips', 'owner_id');
};
exports.down = (pgm) => {
pgm.dropTable('trips');
};

View File

@@ -0,0 +1,23 @@
exports.up = (pgm) => {
pgm.createTable('trip_settings', {
trip_id: {
type: 'uuid',
primaryKey: true,
notNull: true,
references: 'trips(id)',
onDelete: 'CASCADE',
},
web_research_enabled: { type: 'boolean', notNull: true, default: false },
periodic_agent_review_enabled: { type: 'boolean', notNull: true, default: false },
notification_email_enabled: { type: 'boolean', notNull: true, default: true },
notification_push_enabled: { type: 'boolean', notNull: true, default: true },
default_research_depth: { type: 'text' },
default_planning_style: { type: 'text' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
};
exports.down = (pgm) => {
pgm.dropTable('trip_settings');
};

View File

@@ -0,0 +1,25 @@
exports.up = (pgm) => {
pgm.createTable('trip_members', {
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
trip_id: { type: 'uuid', notNull: true, references: 'trips(id)', onDelete: 'CASCADE' },
user_id: { type: 'uuid', notNull: true, references: 'users(id)' },
role: { type: 'text', notNull: true },
status: { type: 'text', notNull: true },
joined_at: { type: 'timestamptz' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
pgm.addConstraint('trip_members', 'trip_members_trip_user_key', 'UNIQUE(trip_id, user_id)');
pgm.addConstraint('trip_members', 'trip_members_role_check', "CHECK (role IN ('OWNER','MEMBER'))");
pgm.addConstraint(
'trip_members',
'trip_members_status_check',
"CHECK (status IN ('INVITED','ACTIVE','DECLINED'))",
);
pgm.createIndex('trip_members', 'trip_id');
pgm.createIndex('trip_members', 'user_id');
};
exports.down = (pgm) => {
pgm.dropTable('trip_members');
};

View File

@@ -0,0 +1,20 @@
exports.up = (pgm) => {
pgm.createTable('trip_invitations', {
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
trip_id: { type: 'uuid', notNull: true, references: 'trips(id)', onDelete: 'CASCADE' },
email: { type: 'text', notNull: true },
invited_by_user_id: { type: 'uuid', notNull: true, references: 'users(id)' },
token_hash: { type: 'text', notNull: true },
expires_at: { type: 'timestamptz', notNull: true },
accepted_at: { type: 'timestamptz' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
pgm.addConstraint('trip_invitations', 'trip_invitations_token_hash_key', 'UNIQUE(token_hash)');
pgm.createIndex('trip_invitations', 'trip_id');
pgm.sql('CREATE INDEX trip_invitations_lower_email_idx ON trip_invitations (lower(email))');
};
exports.down = (pgm) => {
pgm.dropTable('trip_invitations');
};

View File

@@ -0,0 +1,22 @@
exports.up = (pgm) => {
pgm.createTable('travelers', {
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
trip_id: { type: 'uuid', notNull: true, references: 'trips(id)', onDelete: 'CASCADE' },
linked_user_id: { type: 'uuid', references: 'users(id)' },
display_name: { type: 'text', notNull: true },
traveler_type: { type: 'text', notNull: true },
created_by_user_id: { type: 'uuid', notNull: true, references: 'users(id)' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
pgm.addConstraint(
'travelers',
'travelers_traveler_type_check',
"CHECK (traveler_type IN ('ADULT','CHILD','INFANT'))",
);
pgm.createIndex('travelers', 'trip_id');
};
exports.down = (pgm) => {
pgm.dropTable('travelers');
};

View File

@@ -0,0 +1,30 @@
exports.up = (pgm) => {
pgm.createTable('trip_preference_overrides', {
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
trip_id: { type: 'uuid', notNull: true, references: 'trips(id)', onDelete: 'CASCADE' },
user_id: { type: 'uuid', references: 'users(id)' },
traveler_id: { type: 'uuid', references: 'travelers(id)', onDelete: 'CASCADE' },
overrides: { type: 'jsonb', notNull: true, default: '{}' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
pgm.addConstraint(
'trip_preference_overrides',
'trip_preference_overrides_subject_check',
'CHECK (num_nonnulls(user_id, traveler_id) = 1)',
);
pgm.createIndex('trip_preference_overrides', ['trip_id', 'user_id'], {
unique: true,
where: 'user_id IS NOT NULL',
name: 'trip_pref_overrides_trip_user_uq',
});
pgm.createIndex('trip_preference_overrides', ['trip_id', 'traveler_id'], {
unique: true,
where: 'traveler_id IS NOT NULL',
name: 'trip_pref_overrides_trip_traveler_uq',
});
};
exports.down = (pgm) => {
pgm.dropTable('trip_preference_overrides');
};