205 lines
6.5 KiB
TypeScript
205 lines
6.5 KiB
TypeScript
import {
|
|
boolean,
|
|
date,
|
|
doublePrecision,
|
|
index,
|
|
integer,
|
|
numeric,
|
|
pgEnum,
|
|
pgTable,
|
|
primaryKey,
|
|
serial,
|
|
text,
|
|
timestamp,
|
|
uniqueIndex,
|
|
} from "drizzle-orm/pg-core";
|
|
|
|
export const categoryEnum = pgEnum("category", [
|
|
"food",
|
|
"cafe",
|
|
"shopping",
|
|
"entertainment",
|
|
"other",
|
|
]);
|
|
export const collectionTypeEnum = pgEnum("collection_type", ["folder", "trip"]);
|
|
export const memberRoleEnum = pgEnum("member_role", ["owner", "editor", "viewer"]);
|
|
export const priceRangeEnum = pgEnum("price_range", ["$", "$$", "$$$"]);
|
|
|
|
export const users = pgTable("users", {
|
|
id: serial("id").primaryKey(),
|
|
email: text("email").notNull().unique(),
|
|
passwordHash: text("password_hash").notNull(),
|
|
name: text("name").notNull(),
|
|
initials: text("initials").notNull(),
|
|
avatarUrl: text("avatar_url"),
|
|
color: text("color"),
|
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
// Session token is a cryptographic secret — must stay random text.
|
|
// Sequential ints would be guessable and enable account takeover.
|
|
export const sessions = pgTable(
|
|
"sessions",
|
|
{
|
|
id: text("id").primaryKey(),
|
|
userId: integer("user_id")
|
|
.notNull()
|
|
.references(() => users.id, { onDelete: "cascade" }),
|
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
// NULL = never expires (preferred for new sessions). Older rows with
|
|
// a timestamp value are still honoured for backward compatibility.
|
|
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
|
userAgent: text("user_agent"),
|
|
},
|
|
(t) => [
|
|
index("sessions_user_id_idx").on(t.userId),
|
|
index("sessions_expires_at_idx").on(t.expiresAt),
|
|
],
|
|
);
|
|
|
|
export const places = pgTable(
|
|
"places",
|
|
{
|
|
id: serial("id").primaryKey(),
|
|
createdBy: integer("created_by")
|
|
.notNull()
|
|
.references(() => users.id),
|
|
name: text("name").notNull(),
|
|
address: text("address").notNull(),
|
|
shortAddress: text("short_address").notNull(),
|
|
city: text("city").notNull(),
|
|
lat: doublePrecision("lat"),
|
|
lng: doublePrecision("lng"),
|
|
category: categoryEnum("category").notNull(),
|
|
tags: text("tags").array().notNull().default([]),
|
|
coverUrl: text("cover_url"),
|
|
phone: text("phone"),
|
|
website: text("website"),
|
|
priceRange: priceRangeEnum("price_range"),
|
|
openingHours: text("opening_hours"),
|
|
permanentlyClosed: boolean("permanently_closed").notNull().default(false),
|
|
avgRating: numeric("avg_rating", { precision: 3, scale: 2 }),
|
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(t) => [
|
|
index("places_created_by_idx").on(t.createdBy),
|
|
index("places_category_idx").on(t.category),
|
|
],
|
|
);
|
|
|
|
export const userPlaceData = pgTable(
|
|
"user_place_data",
|
|
{
|
|
userId: integer("user_id")
|
|
.notNull()
|
|
.references(() => users.id),
|
|
placeId: integer("place_id")
|
|
.notNull()
|
|
.references(() => places.id, { onDelete: "cascade" }),
|
|
notes: text("notes"),
|
|
rating: integer("rating"),
|
|
visited: boolean("visited").notNull().default(false),
|
|
visitedAt: timestamp("visited_at", { withTimezone: true }),
|
|
},
|
|
(t) => [primaryKey({ columns: [t.userId, t.placeId] })],
|
|
);
|
|
|
|
export const collections = pgTable("collections", {
|
|
id: serial("id").primaryKey(),
|
|
ownerId: integer("owner_id")
|
|
.notNull()
|
|
.references(() => users.id),
|
|
name: text("name").notNull(),
|
|
type: collectionTypeEnum("type").notNull(),
|
|
tripStart: date("trip_start"),
|
|
tripEnd: date("trip_end"),
|
|
inviteToken: text("invite_token").unique(),
|
|
tokenExpiresAt: timestamp("token_expires_at", { withTimezone: true }),
|
|
publicToken: text("public_token").unique(),
|
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
export const collectionMembers = pgTable(
|
|
"collection_members",
|
|
{
|
|
collectionId: integer("collection_id")
|
|
.notNull()
|
|
.references(() => collections.id, { onDelete: "cascade" }),
|
|
userId: integer("user_id")
|
|
.notNull()
|
|
.references(() => users.id),
|
|
role: memberRoleEnum("role").notNull(),
|
|
joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(t) => [primaryKey({ columns: [t.collectionId, t.userId] })],
|
|
);
|
|
|
|
export const collectionPlaces = pgTable(
|
|
"collection_places",
|
|
{
|
|
collectionId: integer("collection_id")
|
|
.notNull()
|
|
.references(() => collections.id, { onDelete: "cascade" }),
|
|
placeId: integer("place_id")
|
|
.notNull()
|
|
.references(() => places.id, { onDelete: "cascade" }),
|
|
addedBy: integer("added_by")
|
|
.notNull()
|
|
.references(() => users.id),
|
|
sortOrder: integer("sort_order").notNull().default(0),
|
|
addedAt: timestamp("added_at", { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(t) => [
|
|
primaryKey({ columns: [t.collectionId, t.placeId] }),
|
|
index("collection_places_place_idx").on(t.placeId),
|
|
],
|
|
);
|
|
|
|
// Email-targeted invitation — one per (collection, email). Token is the
|
|
// shareable secret; once accepted, the user joins the collection at `role`.
|
|
export const invitations = pgTable(
|
|
"invitations",
|
|
{
|
|
id: serial("id").primaryKey(),
|
|
collectionId: integer("collection_id")
|
|
.notNull()
|
|
.references(() => collections.id, { onDelete: "cascade" }),
|
|
email: text("email").notNull(),
|
|
role: memberRoleEnum("role").notNull(),
|
|
token: text("token").notNull().unique(),
|
|
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
|
invitedBy: integer("invited_by")
|
|
.notNull()
|
|
.references(() => users.id),
|
|
acceptedAt: timestamp("accepted_at", { withTimezone: true }),
|
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(t) => [
|
|
uniqueIndex("invitations_collection_email_idx").on(t.collectionId, t.email),
|
|
index("invitations_token_idx").on(t.token),
|
|
],
|
|
);
|
|
|
|
export const placeReviews = pgTable(
|
|
"place_reviews",
|
|
{
|
|
id: serial("id").primaryKey(),
|
|
placeId: integer("place_id")
|
|
.notNull()
|
|
.references(() => places.id, { onDelete: "cascade" }),
|
|
collectionId: integer("collection_id")
|
|
.notNull()
|
|
.references(() => collections.id, { onDelete: "cascade" }),
|
|
userId: integer("user_id")
|
|
.notNull()
|
|
.references(() => users.id),
|
|
body: text("body").notNull(),
|
|
rating: integer("rating"),
|
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(t) => [
|
|
uniqueIndex("place_reviews_unique").on(t.placeId, t.collectionId, t.userId),
|
|
],
|
|
);
|