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