Files
places/src/lib/db/schema.ts
2026-05-20 18:08:37 +07:00

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),
],
);