feat: add user centric features
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS handle;
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS plan;
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Adds the social-handle + subscription-plan columns to `users`.
|
||||
--
|
||||
-- handle — case-insensitive (citext) URL slug, e.g. /u/satya-das.
|
||||
-- Auto-generated from the email-local part on signup; user can
|
||||
-- change it later via PATCH /users/me. Unique across the table.
|
||||
-- plan — 'free' (default) or 'premium'. Controls limits enforced at
|
||||
-- the service layer (bookmarks count, collection counts).
|
||||
-- No checkout flow yet — admin flips it with SQL.
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS handle CITEXT,
|
||||
ADD COLUMN IF NOT EXISTS plan VARCHAR(16) NOT NULL DEFAULT 'free'
|
||||
CHECK (plan IN ('free', 'premium'));
|
||||
|
||||
-- Backfill handles for any rows that already exist. The recipe mirrors the
|
||||
-- generator the auth service uses: slugify the local-part of the email and
|
||||
-- append a 6-char random suffix to make collisions vanishingly unlikely.
|
||||
UPDATE users
|
||||
SET handle = LOWER(REGEXP_REPLACE(SPLIT_PART(email::text, '@', 1), '[^a-z0-9]', '', 'g'))
|
||||
|| '-' || substr(md5(id::text), 1, 6)
|
||||
WHERE handle IS NULL;
|
||||
|
||||
ALTER TABLE users ALTER COLUMN handle SET NOT NULL;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'users_handle_key'
|
||||
) THEN
|
||||
ALTER TABLE users ADD CONSTRAINT users_handle_key UNIQUE (handle);
|
||||
END IF;
|
||||
END$$;
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS post_images;
|
||||
DROP TABLE IF EXISTS posts;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Posts are public quotes pulled from the knowledgebase. Each carries the
|
||||
-- selected text verbatim as its caption (immutable after create) plus a link
|
||||
-- back to the source URL the user was reading. Optional images are stored in
|
||||
-- the `post_images` table, one row per image, ordered by `position`.
|
||||
--
|
||||
-- Posts are always public — visibility is a per-user concern (the user's
|
||||
-- /u/<handle> feed), not a per-post one. Private saves should use bookmarks.
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
caption TEXT NOT NULL,
|
||||
source_url TEXT NOT NULL,
|
||||
source_title VARCHAR(200),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS posts_user_created_idx
|
||||
ON posts (user_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS post_images (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS post_images_post_idx
|
||||
ON post_images (post_id, position);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS bookmarks;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Bookmarks are always private — they're the user's "save my place" markers.
|
||||
-- We don't store the full passage text, only the source URL + a short excerpt
|
||||
-- so the bookmarks index can render meaningful previews without a separate
|
||||
-- content fetch.
|
||||
--
|
||||
-- UNIQUE (user_id, source_url) means re-bookmarking the same passage is a
|
||||
-- no-op rather than producing a duplicate; the service maps the unique
|
||||
-- violation to a 200 idempotent response.
|
||||
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
source_url TEXT NOT NULL,
|
||||
source_title VARCHAR(200),
|
||||
excerpt TEXT,
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (user_id, source_url)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS bookmarks_user_created_idx
|
||||
ON bookmarks (user_id, created_at DESC);
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP TRIGGER IF EXISTS collection_items_count_trg ON collection_items;
|
||||
DROP FUNCTION IF EXISTS sync_collection_item_count();
|
||||
DROP TABLE IF EXISTS collection_items;
|
||||
DROP TABLE IF EXISTS collections;
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Collections group posts/quotes. They carry their own visibility flag —
|
||||
-- a private collection holds posts that are themselves public on the user's
|
||||
-- feed, but the collection page is only visible to the owner.
|
||||
--
|
||||
-- `item_count` is denormalized so listing pages don't need a join+count.
|
||||
-- It's kept in sync by collection_items triggers below.
|
||||
CREATE TABLE IF NOT EXISTS collections (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title VARCHAR(120) NOT NULL,
|
||||
description TEXT,
|
||||
cover_url TEXT,
|
||||
is_public BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
item_count INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS collections_user_created_idx
|
||||
ON collections (user_id, created_at DESC);
|
||||
|
||||
-- Public-collection lookups by owner+visibility (for /u/<handle> page).
|
||||
CREATE INDEX IF NOT EXISTS collections_user_public_idx
|
||||
ON collections (user_id, is_public) WHERE is_public = TRUE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collection_items (
|
||||
collection_id UUID NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (collection_id, post_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS collection_items_position_idx
|
||||
ON collection_items (collection_id, position);
|
||||
|
||||
-- Keep item_count in sync.
|
||||
CREATE OR REPLACE FUNCTION sync_collection_item_count() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
UPDATE collections SET item_count = item_count + 1, updated_at = now()
|
||||
WHERE id = NEW.collection_id;
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
UPDATE collections SET item_count = GREATEST(item_count - 1, 0), updated_at = now()
|
||||
WHERE id = OLD.collection_id;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS collection_items_count_trg ON collection_items;
|
||||
CREATE TRIGGER collection_items_count_trg
|
||||
AFTER INSERT OR DELETE ON collection_items
|
||||
FOR EACH ROW EXECUTE FUNCTION sync_collection_item_count();
|
||||
Reference in New Issue
Block a user