feat: add user centric features

This commit is contained in:
Krispire Admin
2026-05-30 23:43:27 +05:30
parent 97422c1ef5
commit aecdd1030a
21 changed files with 2037 additions and 69 deletions
@@ -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$$;
+2
View File
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS post_images;
DROP TABLE IF EXISTS posts;
+30
View File
@@ -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;
+21
View File
@@ -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;
+54
View File
@@ -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();