diff --git a/apps/database/drizzle/0006_feed_controls.sql b/apps/database/drizzle/0006_feed_controls.sql new file mode 100644 index 0000000..d842962 --- /dev/null +++ b/apps/database/drizzle/0006_feed_controls.sql @@ -0,0 +1,12 @@ +CREATE TABLE "org_blocks" ( + "user_id" uuid NOT NULL, + "org_id" uuid NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "org_blocks_user_id_org_id_pk" PRIMARY KEY("user_id","org_id") +); +--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "announcement_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "announced_at" timestamp;--> statement-breakpoint +ALTER TABLE "org_blocks" ADD CONSTRAINT "org_blocks_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "org_blocks" ADD CONSTRAINT "org_blocks_org_id_organizations_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "org_blocks_user_id_idx" ON "org_blocks" USING btree ("user_id"); \ No newline at end of file diff --git a/apps/database/drizzle/meta/0006_snapshot.json b/apps/database/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..d8362b6 --- /dev/null +++ b/apps/database/drizzle/meta/0006_snapshot.json @@ -0,0 +1,2027 @@ +{ + "id": "2cd6094f-33e0-4aa1-a700-002ee53f3861", + "prevId": "9345e621-b354-4574-95a7-965d40fd27c7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.campus_locations": { + "name": "campus_locations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(100)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "location_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event_tag_embeddings": { + "name": "event_tag_embeddings", + "schema": "", + "columns": { + "tag_name": { + "name": "tag_name", + "type": "event_tag", + "typeSchema": "public", + "primaryKey": true, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event_tags": { + "name": "event_tags", + "schema": "", + "columns": { + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "event_tag", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "event_tags_tag_idx": { + "name": "event_tags_tag_idx", + "columns": [ + { + "expression": "tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "event_tags_event_id_events_id_fk": { + "name": "event_tags_event_id_events_id_fk", + "tableFrom": "event_tags", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "event_tags_event_id_tag_pk": { + "name": "event_tags_event_id_tag_pk", + "columns": [ + "event_id", + "tag" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "datetime": { + "name": "datetime", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_datetime": { + "name": "end_datetime", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "location_id": { + "name": "location_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "flyer_url": { + "name": "flyer_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_preset": { + "name": "cover_preset", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "external_link": { + "name": "external_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "event_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'published'" + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "source_message_id": { + "name": "source_message_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location_detail": { + "name": "location_detail", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "announcement_count": { + "name": "announcement_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "announced_at": { + "name": "announced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_published_datetime_idx": { + "name": "events_published_datetime_idx", + "columns": [ + { + "expression": "datetime", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"events\".\"status\" = 'published'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_org_id_idx": { + "name": "events_org_id_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_creator_id_idx": { + "name": "events_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_location_id_idx": { + "name": "events_location_id_idx", + "columns": [ + { + "expression": "location_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_location_id_campus_locations_id_fk": { + "name": "events_location_id_campus_locations_id_fk", + "tableFrom": "events", + "tableTo": "campus_locations", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_org_id_organizations_id_fk": { + "name": "events_org_id_organizations_id_fk", + "tableFrom": "events", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "events_creator_id_users_id_fk": { + "name": "events_creator_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "events_source_message_id_unique": { + "name": "events_source_message_id_unique", + "nullsNotDistinct": false, + "columns": [ + "source_message_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.friendships": { + "name": "friendships", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "friend_id": { + "name": "friend_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "friendship_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "friendships_friend_id_status_idx": { + "name": "friendships_friend_id_status_idx", + "columns": [ + { + "expression": "friend_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "friendships_user_id_users_id_fk": { + "name": "friendships_user_id_users_id_fk", + "tableFrom": "friendships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "friendships_friend_id_users_id_fk": { + "name": "friendships_friend_id_users_id_fk", + "tableFrom": "friendships", + "tableTo": "users", + "columnsFrom": [ + "friend_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "friendships_user_id_friend_id_pk": { + "name": "friendships_user_id_friend_id_pk", + "columns": [ + "user_id", + "friend_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interactions": { + "name": "interactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_type": { + "name": "item_type", + "type": "item_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'event'" + }, + "interaction_type": { + "name": "interaction_type", + "type": "interaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "interaction_value": { + "name": "interaction_value", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interactions_user_id_created_at_idx": { + "name": "interactions_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interactions_item_id_type_idx": { + "name": "interactions_item_id_type_idx", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "interaction_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interactions_user_id_users_id_fk": { + "name": "interactions_user_id_users_id_fk", + "tableFrom": "interactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listserv_configs": { + "name": "listserv_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "address": { + "name": "address", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "gmail_label": { + "name": "gmail_label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "listserv_configs_org_id_organizations_id_fk": { + "name": "listserv_configs_org_id_organizations_id_fk", + "tableFrom": "listserv_configs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "listserv_configs_address_unique": { + "name": "listserv_configs_address_unique", + "nullsNotDistinct": false, + "columns": [ + "address" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listserv_emails": { + "name": "listserv_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "message_id": { + "name": "message_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "listserv": { + "name": "listserv", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_name": { + "name": "author_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "author_email": { + "name": "author_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_hoagiemail": { + "name": "is_hoagiemail", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hoagiemail_sender_name": { + "name": "hoagiemail_sender_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "hoagiemail_sender_email": { + "name": "hoagiemail_sender_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "links": { + "name": "links", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "attachments": { + "name": "attachments", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "listserv_url": { + "name": "listserv_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "listserv_emails_message_id_unique": { + "name": "listserv_emails_message_id_unique", + "nullsNotDistinct": false, + "columns": [ + "message_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_user_id_created_at_idx": { + "name": "notifications_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_blocks": { + "name": "org_blocks", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_blocks_user_id_idx": { + "name": "org_blocks_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_blocks_user_id_users_id_fk": { + "name": "org_blocks_user_id_users_id_fk", + "tableFrom": "org_blocks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_blocks_org_id_organizations_id_fk": { + "name": "org_blocks_org_id_organizations_id_fk", + "tableFrom": "org_blocks", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_blocks_user_id_org_id_pk": { + "name": "org_blocks_user_id_org_id_pk", + "columns": [ + "user_id", + "org_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_followers": { + "name": "org_followers", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_followers_user_id_idx": { + "name": "org_followers_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_followers_org_id_organizations_id_fk": { + "name": "org_followers_org_id_organizations_id_fk", + "tableFrom": "org_followers", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_followers_user_id_users_id_fk": { + "name": "org_followers_user_id_users_id_fk", + "tableFrom": "org_followers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_followers_org_id_user_id_pk": { + "name": "org_followers_org_id_user_id_pk", + "columns": [ + "org_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_members": { + "name": "org_members", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "org_members_user_id_idx": { + "name": "org_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_members_org_id_organizations_id_fk": { + "name": "org_members_org_id_organizations_id_fk", + "tableFrom": "org_members", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_members_user_id_users_id_fk": { + "name": "org_members_user_id_users_id_fk", + "tableFrom": "org_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_members_org_id_user_id_pk": { + "name": "org_members_org_id_user_id_pk", + "columns": [ + "org_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "org_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "external_id": { + "name": "external_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "acronym": { + "name": "acronym", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "group_type": { + "name": "group_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "group_url": { + "name": "group_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contact_email": { + "name": "contact_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "socials": { + "name": "socials", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "member_count": { + "name": "member_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organizations_creator_id_users_id_fk": { + "name": "organizations_creator_id_users_id_fk", + "tableFrom": "organizations", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_name_unique": { + "name": "organizations_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "organizations_external_id_unique": { + "name": "organizations_external_id_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_logs": { + "name": "pipeline_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "message_id": { + "name": "message_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "listserv_config_id": { + "name": "listserv_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "pipeline_log_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "error_text": { + "name": "error_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "extracted_event_id": { + "name": "extracted_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pipeline_logs_listserv_config_id_listserv_configs_id_fk": { + "name": "pipeline_logs_listserv_config_id_listserv_configs_id_fk", + "tableFrom": "pipeline_logs", + "tableTo": "listserv_configs", + "columnsFrom": [ + "listserv_config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pipeline_logs_extracted_event_id_events_id_fk": { + "name": "pipeline_logs_extracted_event_id_events_id_fk", + "tableFrom": "pipeline_logs", + "tableTo": "events", + "columnsFrom": [ + "extracted_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rsvps": { + "name": "rsvps", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rsvps_event_id_idx": { + "name": "rsvps_event_id_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rsvps_user_id_users_id_fk": { + "name": "rsvps_user_id_users_id_fk", + "tableFrom": "rsvps", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rsvps_event_id_events_id_fk": { + "name": "rsvps_event_id_events_id_fk", + "tableFrom": "rsvps", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "rsvps_user_id_event_id_pk": { + "name": "rsvps_user_id_event_id_pk", + "columns": [ + "user_id", + "event_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_events": { + "name": "saved_events", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "saved_events_event_id_idx": { + "name": "saved_events_event_id_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_events_user_id_users_id_fk": { + "name": "saved_events_user_id_users_id_fk", + "tableFrom": "saved_events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "saved_events_event_id_events_id_fk": { + "name": "saved_events_event_id_events_id_fk", + "tableFrom": "saved_events", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "saved_events_user_id_event_id_pk": { + "name": "saved_events_user_id_event_id_pk", + "columns": [ + "user_id", + "event_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_state": { + "name": "sync_state", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(100)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_interests": { + "name": "user_interests", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "event_tag", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_interests_user_id_users_id_fk": { + "name": "user_interests_user_id_users_id_fk", + "tableFrom": "user_interests", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_interests_user_id_tag_pk": { + "name": "user_interests_user_id_tag_pk", + "columns": [ + "user_id", + "tag" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preference_vectors": { + "name": "user_preference_vectors", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "tag_weights": { + "name": "tag_weights", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "latent_vector": { + "name": "latent_vector", + "type": "double precision[]", + "primaryKey": false, + "notNull": false + }, + "interaction_count": { + "name": "interaction_count", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preference_vectors_user_id_users_id_fk": { + "name": "user_preference_vectors_user_id_users_id_fk", + "tableFrom": "user_preference_vectors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_regions": { + "name": "user_regions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "region": { + "name": "region", + "type": "campus_region", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_regions_user_id_users_id_fk": { + "name": "user_regions_user_id_users_id_fk", + "tableFrom": "user_regions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_regions_user_id_region_pk": { + "name": "user_regions_user_id_region_pk", + "columns": [ + "user_id", + "region" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "net_id": { + "name": "net_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "class_year": { + "name": "class_year", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "major": { + "name": "major", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_org_leader": { + "name": "is_org_leader", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "onboarded": { + "name": "onboarded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "calendar_token": { + "name": "calendar_token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_net_id_unique": { + "name": "users_net_id_unique", + "nullsNotDistinct": false, + "columns": [ + "net_id" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_calendar_token_unique": { + "name": "users_calendar_token_unique", + "nullsNotDistinct": false, + "columns": [ + "calendar_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.campus_region": { + "name": "campus_region", + "schema": "public", + "values": [ + "central", + "east", + "west", + "south", + "north", + "off-campus" + ] + }, + "public.event_status": { + "name": "event_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.event_tag": { + "name": "event_tag", + "schema": "public", + "values": [ + "free food", + "career", + "research", + "academics", + "tech", + "entrepreneurship", + "politics", + "visual arts", + "performing arts", + "literature", + "culture", + "music", + "gaming", + "athletics", + "religion", + "sustainability", + "outdoors", + "wellness", + "community service", + "speaker event", + "social event", + "stem" + ] + }, + "public.friendship_status": { + "name": "friendship_status", + "schema": "public", + "values": [ + "pending", + "accepted", + "declined" + ] + }, + "public.interaction_type": { + "name": "interaction_type", + "schema": "public", + "values": [ + "view", + "click", + "rsvp", + "save", + "share", + "hide" + ] + }, + "public.item_type": { + "name": "item_type", + "schema": "public", + "values": [ + "event", + "organization" + ] + }, + "public.location_category": { + "name": "location_category", + "schema": "public", + "values": [ + "academic", + "residential", + "athletic", + "social", + "administrative", + "library", + "dining", + "other" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "friend_request", + "event_reminder", + "org_new_event" + ] + }, + "public.org_category": { + "name": "org_category", + "schema": "public", + "values": [ + "career", + "affinity", + "performing arts", + "academics", + "athletics", + "social event", + "culture", + "religion", + "politics", + "community service" + ] + }, + "public.org_role": { + "name": "org_role", + "schema": "public", + "values": [ + "owner", + "officer", + "member" + ] + }, + "public.pipeline_log_status": { + "name": "pipeline_log_status", + "schema": "public", + "values": [ + "success", + "skipped_not_event", + "duplicate", + "error", + "needs_review" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/database/drizzle/meta/_journal.json b/apps/database/drizzle/meta/_journal.json index af25693..9fc4a1a 100644 --- a/apps/database/drizzle/meta/_journal.json +++ b/apps/database/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1790583252834, "tag": "0005_user_calendar_token", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1790585035452, + "tag": "0006_feed_controls", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/database/src/index.ts b/apps/database/src/index.ts index 82da124..72b5fa6 100644 --- a/apps/database/src/index.ts +++ b/apps/database/src/index.ts @@ -9,6 +9,7 @@ export { asc, sql, inArray, + notInArray, like, ilike, count, diff --git a/apps/database/src/schema/index.ts b/apps/database/src/schema/index.ts index 69ab2fd..e3d606a 100644 --- a/apps/database/src/schema/index.ts +++ b/apps/database/src/schema/index.ts @@ -214,6 +214,24 @@ export const orgFollowers = pgTable( ], ); +/** Orgs a user has hidden: their events are removed from every discovery surface. */ +export const orgBlocks = pgTable( + "org_blocks", + { + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + orgId: uuid("org_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (t) => [ + primaryKey({ columns: [t.userId, t.orgId] }), + index("org_blocks_user_id_idx").on(t.userId), + ], +); + export const events = pgTable( "events", { @@ -244,6 +262,14 @@ export const events = pgTable( sourceUrl: text("source_url"), /** Room or free-text place beyond the campus location ("Room 104", "Zoom"). */ locationDetail: varchar("location_detail", { length: 200 }), + /** + * How many campus listserv emails announced this event (its own source + * email plus folded-in reminders), from InboxEngine. 0 for unannounced + * MyPrincetonU listings and Forum-created events. A ranking signal. + */ + announcementCount: integer("announcement_count").default(0).notNull(), + /** When the event was first announced on a listserv, if ever ("Recently posted" sort). */ + announcedAt: timestamp("announced_at"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }, diff --git a/apps/database/src/sync-inbox-engine.ts b/apps/database/src/sync-inbox-engine.ts index 8b10f65..a104bd5 100644 --- a/apps/database/src/sync-inbox-engine.ts +++ b/apps/database/src/sync-inbox-engine.ts @@ -229,16 +229,28 @@ export async function syncEvents(client = engineClient()) { status: events.status, isPublic: events.isPublic, updatedAt: events.updatedAt, + announcementCount: events.announcementCount, + announcedAt: events.announcedAt, }) .from(events) .where(eq(events.sourceMessageId, key)); const publish = shouldPublish(e, seriesRank); const isPublished = existing?.status === "published" && existing.isPublic; + const announcementCount = e.announcements.emails; + const announcedAt = e.announcements.firstAnnouncedAt + ? new Date(e.announcements.firstAnnouncedAt) + : null; + // InboxEngine bumps updatedAt when announcements are folded in; comparing + // the stored signals too backfills rows written before these columns existed. + const announcementsCurrent = + existing?.announcementCount === announcementCount && + (existing.announcedAt?.getTime() ?? null) === (announcedAt?.getTime() ?? null); // Unchanged upstream and already in the desired state: nothing to write. if ( existing && publish === isPublished && - existing.updatedAt.getTime() >= new Date(e.updatedAt).getTime() + existing.updatedAt.getTime() >= new Date(e.updatedAt).getTime() && + (!publish || announcementsCurrent) ) { stats.skipped++; continue; @@ -278,6 +290,8 @@ export async function syncEvents(client = engineClient()) { status: "published" as const, source: e.source.kind, sourceMessageId: key, + announcementCount, + announcedAt, updatedAt: new Date(), }; let eventId = existing?.id; diff --git a/apps/web/src/actions/events.ts b/apps/web/src/actions/events.ts index 010ce66..8429a55 100644 --- a/apps/web/src/actions/events.ts +++ b/apps/web/src/actions/events.ts @@ -31,8 +31,10 @@ import { eventDiscoverableBy, eventEditableBy, eventVisibleTo, + notFromHiddenOrg, } from "~/lib/event-visibility"; import { type FeedPage, loadRankedFeed } from "~/lib/feed"; +import { loadOrgViewerState } from "~/lib/org-preferences"; import { enforceRateLimit } from "~/lib/rate-limit"; import { isOurImageUrl, uploadedImageUrlSchema } from "~/lib/s3"; import { loadFriendIds } from "~/lib/social-graph"; @@ -40,6 +42,7 @@ import { dateInputSchema, eventStatusSchema, eventTagSchema, + feedSortSchema, httpsUrlSchema, idSchema, locationIdSchema, @@ -56,6 +59,8 @@ export interface FeedEvent { orgName: string | null; /** Organization logo (MyPrincetonU groups often have one), else null. */ orgLogoUrl?: string | null; + /** The viewer follows the host org — drives the card menu's Follow/Unfollow. Explore only. */ + isFollowingOrg?: boolean; datetime: string; /** ISO timestamp, for building calendar links client-side. */ rawDatetime?: string; @@ -172,6 +177,7 @@ const feedParamsSchema = z.object({ orgCategory: orgCategorySchema.optional(), locationId: z.string().max(100).optional(), dateRange: z.enum(["today", "week", "month"]).optional(), + sort: feedSortSchema.default("foryou"), limit: z.number().int().min(1).max(50).default(20), offset: z.number().int().min(0).max(5000).default(0), asOf: z.iso.datetime().optional(), @@ -184,18 +190,20 @@ export interface FeedParams { orgCategory?: string; locationId?: string; dateRange?: "today" | "week" | "month"; + /** "foryou" (default) | "soonest" | "recent". */ + sort?: string; limit?: number; offset?: number; asOf?: string; } /** - * One page of the ranked Explore feed. Ranking, org-diversity and the - * soon-event quota are applied to the whole candidate list before paginating; - * see `~/lib/feed.ts` and docs/ranking.md. + * One page of the Explore feed in the requested sort. Ordering (for "For you": + * ranking, org-diversity and the soon-event quota) is applied to the whole + * candidate list before paginating; see `~/lib/feed.ts` and docs/ranking.md. * - * Pass the returned `asOf` back with the next `offset` so later pages are - * slices of the same ranking. + * Pass the returned `asOf` and `nextOffset` back for the next page so later + * pages are slices of the same ordering. */ export async function getFeedEvents(params?: FeedParams): Promise { const session = await auth(); @@ -209,6 +217,7 @@ export async function getFeedEvents(params?: FeedParams): Promise { orgCategory: input.orgCategory, locationId: input.locationId, dateRange: input.dateRange, + sort: input.sort, limit: input.limit, offset: input.offset, asOf: input.asOf ? new Date(input.asOf) : undefined, @@ -340,6 +349,10 @@ export interface EventDetail { isOwner: boolean; /** The viewer may edit it: creator, or owner/officer of its org. */ canEdit: boolean; + /** The viewer follows the host org. */ + isFollowingOrg: boolean; + /** The viewer hid the host org's events from discovery. */ + isOrgHidden: boolean; } export async function getEvent(eventId: string): Promise { @@ -382,9 +395,10 @@ export async function getEvent(eventId: string): Promise { if (!event) return null; - const [friendIds, canEdit] = await Promise.all([ + const [friendIds, canEdit, orgState] = await Promise.all([ loadFriendIds(userId), event.creatorId === userId ? Promise.resolve(true) : canEditEvent(eventId, userId), + event.orgId ? loadOrgViewerState(userId, event.orgId) : null, ]); const extra = await loadEventEnrichment([eventId], userId, friendIds); const attendees = extra.attendees.get(eventId) ?? []; @@ -416,6 +430,8 @@ export async function getEvent(eventId: string): Promise { isSaved: extra.savedByMe.has(eventId), isOwner: event.creatorId === userId, canEdit, + isFollowingOrg: orgState?.following ?? false, + isOrgHidden: orgState?.hidden ?? false, }; } @@ -439,6 +455,7 @@ export async function getSimilarEvents( const conditions = [ gt(events.datetime, new Date()), eventDiscoverableBy(userId), + notFromHiddenOrg(userId), ne(events.id, input.eventId), ]; @@ -981,6 +998,7 @@ export async function getFriendsEvents(): Promise { inArray(rsvps.userId, friendIds), gt(events.datetime, new Date()), eventDiscoverableBy(userId), + notFromHiddenOrg(userId), ), ) .groupBy( diff --git a/apps/web/src/actions/map.ts b/apps/web/src/actions/map.ts index 040cf97..2d904fe 100644 --- a/apps/web/src/actions/map.ts +++ b/apps/web/src/actions/map.ts @@ -19,7 +19,7 @@ import { import { z } from "zod"; import { auth } from "~/auth"; import { formatTime } from "~/lib/date-format"; -import { eventDiscoverableBy } from "~/lib/event-visibility"; +import { eventDiscoverableBy, notFromHiddenOrg } from "~/lib/event-visibility"; import { loadFriendIds } from "~/lib/social-graph"; import { dateInputSchema, parseInput } from "~/lib/validation"; @@ -85,6 +85,8 @@ export async function getMapEvents(opts?: { lt(events.datetime, endDate), // Published AND visible to this viewer — see ~/lib/event-visibility.ts. eventDiscoverableBy(userId), + // Not from an org the viewer hid. + notFromHiddenOrg(userId), // The "other" placeholder location sits at (0, 0) — off the coast of // Africa. Events there have no real coordinates, so keep them off the map. or(ne(campusLocations.latitude, 0), ne(campusLocations.longitude, 0)), diff --git a/apps/web/src/actions/orgs.ts b/apps/web/src/actions/orgs.ts index e5b7396..d0bcbd3 100644 --- a/apps/web/src/actions/orgs.ts +++ b/apps/web/src/actions/orgs.ts @@ -11,6 +11,7 @@ import { ilike, inArray, or, + orgBlocks, orgFollowers, orgMembers, organizations, @@ -23,6 +24,7 @@ import { z } from "zod"; import { auth } from "~/auth"; import { formatEventDateTime } from "~/lib/date-format"; import { eventVisibleTo } from "~/lib/event-visibility"; +import { clearFollowing, clearHidden, setFollowing, setHidden } from "~/lib/org-preferences"; import { enforceRateLimit } from "~/lib/rate-limit"; import { uploadedImageUrlSchema } from "~/lib/s3"; import { loadFriendIds } from "~/lib/social-graph"; @@ -37,6 +39,8 @@ export interface OrgListItem { category: string; followerCount: number; isFollowing: boolean; + /** The viewer hid this org's events from their feed. */ + isHidden: boolean; } export interface OrgPerson { @@ -68,6 +72,8 @@ export interface OrgDetail { memberCount: number | null; followerCount: number; isFollowing: boolean; + /** The viewer hid this org's events from their feed (its page still lists them). */ + isHidden: boolean; isOwner: boolean; /** The viewer's friends who follow this org (social proof; never the full follower list). */ friendsFollowing: OrgPerson[]; @@ -130,7 +136,7 @@ export async function getOrgs(params?: { // Follower counts + the viewer's follow state: two grouped queries for the // whole list, instead of two queries per org. const orgIds = orgs.map((o) => o.id); - const [countRows, myFollows] = await Promise.all([ + const [countRows, myFollows, myHidden] = await Promise.all([ db .select({ orgId: orgFollowers.orgId, count: sql`count(*)::int` }) .from(orgFollowers) @@ -140,14 +146,17 @@ export async function getOrgs(params?: { .select({ orgId: orgFollowers.orgId }) .from(orgFollowers) .where(and(eq(orgFollowers.userId, userId), inArray(orgFollowers.orgId, orgIds))), + db.select({ orgId: orgBlocks.orgId }).from(orgBlocks).where(eq(orgBlocks.userId, userId)), ]); const countByOrg = new Map(countRows.map((r) => [r.orgId, r.count])); const followed = new Set(myFollows.map((r) => r.orgId)); + const hidden = new Set(myHidden.map((r) => r.orgId)); return orgs.map((org) => ({ ...org, followerCount: countByOrg.get(org.id) ?? 0, isFollowing: followed.has(org.id), + isHidden: hidden.has(org.id), })); } @@ -165,7 +174,7 @@ export async function getOrg(orgId: string): Promise { if (!org) return null; const friendIds = await loadFriendIds(userId); - const [[countResult], [following], friendsFollowing, team] = await Promise.all([ + const [[countResult], [following], [hidden], friendsFollowing, team] = await Promise.all([ db .select({ count: sql`count(*)::int` }) .from(orgFollowers) @@ -175,6 +184,11 @@ export async function getOrg(orgId: string): Promise { .from(orgFollowers) .where(and(eq(orgFollowers.orgId, orgId), eq(orgFollowers.userId, userId))) .limit(1), + db + .select({ userId: orgBlocks.userId }) + .from(orgBlocks) + .where(and(eq(orgBlocks.orgId, orgId), eq(orgBlocks.userId, userId))) + .limit(1), friendIds.length === 0 ? Promise.resolve([] as OrgPerson[]) : db @@ -272,6 +286,7 @@ export async function getOrg(orgId: string): Promise { memberCount: org.memberCount, followerCount: countResult?.count ?? 0, isFollowing: !!following, + isHidden: !!hidden, isOwner: !!org.creatorId && org.creatorId === userId, friendsFollowing, team, @@ -335,12 +350,18 @@ export async function createOrg(data: { return { id: orgId }; } +/** + * Follow or unfollow. Following hoists the org's events in the feed and + * clears a hide (the two are mutually exclusive — see ~/lib/org-preferences). + */ export async function toggleFollowOrg(orgId: string): Promise<{ following: boolean }> { const session = await auth(); if (!session?.user?.id) throw new Error("Unauthorized"); const userId = session.user.id; const id = parseInput(idSchema, orgId); + enforceRateLimit("orgPreference", userId); + await assertOrgExists(id); const [existing] = await db .select({ orgId: orgFollowers.orgId }) @@ -349,13 +370,8 @@ export async function toggleFollowOrg(orgId: string): Promise<{ following: boole .limit(1); // Idempotent either way, so a double-click can't throw on the primary key. - if (existing) { - await db - .delete(orgFollowers) - .where(and(eq(orgFollowers.orgId, id), eq(orgFollowers.userId, userId))); - } else { - await db.insert(orgFollowers).values({ orgId: id, userId }).onConflictDoNothing(); - } + if (existing) await clearFollowing(userId, id); + else await setFollowing(userId, id); revalidatePath(`/orgs/${id}`); revalidatePath("/orgs"); @@ -363,6 +379,99 @@ export async function toggleFollowOrg(orgId: string): Promise<{ following: boole return { following: !existing }; } +async function assertOrgExists(orgId: string) { + const [org] = await db + .select({ id: organizations.id }) + .from(organizations) + .where(eq(organizations.id, orgId)) + .limit(1); + if (!org) throw new Error("Organization not found"); +} + +/** + * Hide an org's events from discovery — Explore (every sort and search), the + * map, similar events and friends' activity. Its own page and direct event + * links still work. Hiding unfollows; `unfollowed` tells the caller whether + * Undo should restore the follow. Idempotent. + */ +export async function blockOrg(orgId: string): Promise<{ hidden: true; unfollowed: boolean }> { + const session = await auth(); + if (!session?.user?.id) throw new Error("Unauthorized"); + + const userId = session.user.id; + const id = parseInput(idSchema, orgId); + enforceRateLimit("orgPreference", userId); + await assertOrgExists(id); + + const { unfollowed } = await setHidden(userId, id); + + revalidatePath(`/orgs/${id}`); + revalidatePath("/orgs"); + + return { hidden: true, unfollowed }; +} + +const unblockOrgSchema = z.object({ + orgId: idSchema, + /** Undo of a hide that unfollowed: follow again. */ + refollow: z.boolean().default(false), +}); + +/** Show a hidden org's events again (optionally re-following it, for Undo). Idempotent. */ +export async function unblockOrg( + orgId: string, + opts?: { refollow?: boolean }, +): Promise<{ hidden: false; following: boolean }> { + const session = await auth(); + if (!session?.user?.id) throw new Error("Unauthorized"); + + const userId = session.user.id; + const input = parseInput(unblockOrgSchema, { orgId, refollow: opts?.refollow }); + enforceRateLimit("orgPreference", userId); + + if (input.refollow) { + await assertOrgExists(input.orgId); + await setFollowing(userId, input.orgId); + } else { + await clearHidden(userId, input.orgId); + } + + revalidatePath(`/orgs/${input.orgId}`); + revalidatePath("/orgs"); + + return { hidden: false, following: input.refollow }; +} + +export interface HiddenOrg { + id: string; + name: string; + logoUrl: string | null; + category: string; + /** ISO timestamp the viewer hid it. */ + hiddenAt: string; +} + +/** Orgs the viewer has hidden, most recently hidden first. */ +export async function getBlockedOrgs(): Promise { + const session = await auth(); + if (!session?.user?.id) throw new Error("Unauthorized"); + + const rows = await db + .select({ + id: organizations.id, + name: organizations.name, + logoUrl: organizations.logoUrl, + category: organizations.category, + hiddenAt: orgBlocks.createdAt, + }) + .from(orgBlocks) + .innerJoin(organizations, eq(orgBlocks.orgId, organizations.id)) + .where(eq(orgBlocks.userId, session.user.id)) + .orderBy(desc(orgBlocks.createdAt), organizations.name); + + return rows.map((r) => ({ ...r, hiddenAt: r.hiddenAt.toISOString() })); +} + const officerSchema = z.object({ orgId: idSchema, userId: idSchema }); async function assertOrgCreator(orgId: string, callerId: string, message: string) { @@ -467,7 +576,13 @@ export async function getRecommendedOrgs(): Promise { .from(orgFollowers) .where(eq(orgFollowers.userId, userId)); - const followedOrgIds = followedOrgs.map((f) => f.orgId); + // Hidden orgs are never suggested either. + const hiddenOrgs = await db + .select({ orgId: orgBlocks.orgId }) + .from(orgBlocks) + .where(eq(orgBlocks.userId, userId)); + + const excludedOrgIds = [...followedOrgs, ...hiddenOrgs].map((f) => f.orgId); // Find orgs whose events have matching tags, ranked by overlap count const recommended = await db @@ -488,9 +603,9 @@ export async function getRecommendedOrgs(): Promise { // Only publicly listed events count as evidence of what an org hosts. eq(events.status, "published"), eq(events.isPublic, true), - followedOrgIds.length > 0 + excludedOrgIds.length > 0 ? sql`${organizations.id} NOT IN (${sql.join( - followedOrgIds.map((id) => sql`${id}`), + excludedOrgIds.map((id) => sql`${id}`), sql`, `, )})` : undefined, @@ -514,5 +629,6 @@ export async function getRecommendedOrgs(): Promise { category: org.category, followerCount: 0, isFollowing: false, + isHidden: false, })); } diff --git a/apps/web/src/actions/users.ts b/apps/web/src/actions/users.ts index 0bd2b1e..398222f 100644 --- a/apps/web/src/actions/users.ts +++ b/apps/web/src/actions/users.ts @@ -4,6 +4,7 @@ import { db, eq, userInterests, userRegions, users } from "@the-forum/database"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { auth } from "~/auth"; +import { greetingName } from "~/lib/greeting-name"; import { uploadedImageUrlSchema } from "~/lib/s3"; import { campusRegionSchema, eventTagSchema, parseInput, uniqueEnumArray } from "~/lib/validation"; @@ -139,6 +140,16 @@ async function getCurrentUser() { return createdUser; } +/** + * The name Home greets the viewer by (see ~/lib/greeting-name), read from the + * database: the session's name is captured at sign-in, so it still holds the + * NetID after onboarding sets a real display name. + */ +export async function getGreetingName(): Promise { + const user = await getCurrentUser(); + return greetingName(user.displayName, user.netId); +} + export async function getUserProfile(): Promise { const user = await getCurrentUser(); diff --git a/apps/web/src/app/(app)/events/[id]/event-detail-client.tsx b/apps/web/src/app/(app)/events/[id]/event-detail-client.tsx index 302fe00..96070ce 100644 --- a/apps/web/src/app/(app)/events/[id]/event-detail-client.tsx +++ b/apps/web/src/app/(app)/events/[id]/event-detail-client.tsx @@ -25,12 +25,14 @@ import { toggleRsvp, toggleSave, } from "~/actions/events"; +import { toggleFollowOrg, unblockOrg } from "~/actions/orgs"; import { OrgAvatar } from "~/components/common/org-avatar"; import { RichText } from "~/components/common/rich-text"; import { AttendeesDialog } from "~/components/events/attendees-dialog"; import { EventCoverArt } from "~/components/events/event-cover-art"; import { MiniEventList } from "~/components/events/mini-event-list"; import { PageShell, SectionHeading } from "~/components/layout/page-shell"; +import { EventActionsMenu, hideOrgWithUndo } from "~/components/orgs/org-feed-menu"; import { AvatarStack } from "~/components/social/avatar-stack"; import { Button } from "~/components/ui/button"; import { @@ -61,6 +63,8 @@ export function EventDetailClient({ event, similarEvents }: EventDetailClientPro /* Local, because the avatar stack below has to move with the RSVP button. */ const [attendees, setAttendees] = useState(event.attendees); const [deleteOpen, setDeleteOpen] = useState(false); + const [isFollowingOrg, setIsFollowingOrg] = useState(event.isFollowingOrg); + const [isOrgHidden, setIsOrgHidden] = useState(event.isOrgHidden); /* * Optimistic, then reconciled with the server. On failure the local state @@ -115,6 +119,56 @@ export function EventDetailClient({ event, similarEvents }: EventDetailClientPro } }; + /* Following unhides; hiding unfollows — mirrored locally. */ + const handleToggleFollowOrg = async () => { + if (!event.orgId || !event.orgName) return; + const prev = { isFollowingOrg, isOrgHidden }; + setIsFollowingOrg(!prev.isFollowingOrg); + if (!prev.isFollowingOrg) setIsOrgHidden(false); + try { + const { following } = await toggleFollowOrg(event.orgId); + setIsFollowingOrg(following); + toast( + following + ? `Following ${event.orgName}. Their events will rank higher.` + : `Unfollowed ${event.orgName}`, + ); + } catch { + setIsFollowingOrg(prev.isFollowingOrg); + setIsOrgHidden(prev.isOrgHidden); + toast.error("Couldn't update that follow. Please try again."); + } + }; + + const handleHideOrg = () => { + if (!event.orgId || !event.orgName) return; + const wasFollowing = isFollowingOrg; + void hideOrgWithUndo({ + orgId: event.orgId, + orgName: event.orgName, + onHidden: () => { + setIsOrgHidden(true); + setIsFollowingOrg(false); + }, + onRestored: () => { + setIsOrgHidden(false); + setIsFollowingOrg(wasFollowing); + }, + }); + }; + + const handleUnhideOrg = async () => { + if (!event.orgId || !event.orgName) return; + setIsOrgHidden(false); + try { + await unblockOrg(event.orgId); + toast(`Events from ${event.orgName} will show in your feed again`); + } catch { + setIsOrgHidden(true); + toast.error("Couldn't update that. Please try again."); + } + }; + const handleDelete = () => { startTransition(async () => { try { @@ -251,6 +305,20 @@ export function EventDetailClient({ event, similarEvents }: EventDetailClientPro + {event.orgId && event.orgName && ( + + )} {/* Attendance */} diff --git a/apps/web/src/app/(app)/explore/explore-client.tsx b/apps/web/src/app/(app)/explore/explore-client.tsx index 9eff653..5438327 100644 --- a/apps/web/src/app/(app)/explore/explore-client.tsx +++ b/apps/web/src/app/(app)/explore/explore-client.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useCallback, useEffect, useRef, useState, useTransition } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"; import { toast } from "sonner"; import { type FeedEvent, @@ -10,39 +10,60 @@ import { toggleRsvp, toggleSave, } from "~/actions/events"; +import { toggleFollowOrg } from "~/actions/orgs"; import { SearchInput } from "~/components/common/search-input"; import { EmptyState, ErrorState, EventCardSkeletonList } from "~/components/common/states"; import { EventCard } from "~/components/events/event-card"; import { EventCollection, EventViewToggle } from "~/components/events/event-collection"; import { EventFilters } from "~/components/events/event-filters"; +import { FeedSortMenu } from "~/components/events/feed-sort-menu"; import { MiniEventList } from "~/components/events/mini-event-list"; import { Greeting } from "~/components/layout/greeting"; import { PageShell, SectionHeading } from "~/components/layout/page-shell"; +import { hideOrgWithUndo } from "~/components/orgs/org-feed-menu"; import { Button } from "~/components/ui/button"; import { buildGCalUrl } from "~/lib/calendar"; import { formatRelativeDay } from "~/lib/date-format"; +import { DEFAULT_FEED_SORT, FEED_SORTS, type FeedSort } from "~/lib/feed-ranking"; import { useEventView } from "~/lib/use-event-view"; +const SORT_STORAGE_KEY = "forum:feed-sort"; + +function isFeedSort(value: unknown): value is FeedSort { + return typeof value === "string" && (FEED_SORTS as readonly string[]).includes(value); +} + interface ExploreClientProps { initialEvents: FeedEvent[]; initialTotal: number; + /** Offset of the next page, and how many visible events lie beyond it. */ + initialNextOffset: number; + initialRemaining: number; /** The instant the first page was ranked at — echoed back so later pages slice the same ranking. */ initialAsOf: string; + initialSort: FeedSort; + /** `?sort=` was in the URL — it wins over the sort remembered in this browser. */ + sortFromUrl: boolean; savedEvents: FeedEvent[]; friendsEvents: FriendsEvent[]; initialSearch?: string; - userName?: string; + /** First name to greet by, or null for a plain "Hello," (see ~/lib/greeting-name). */ + greetingName: string | null; userAvatarUrl?: string | null; } export function ExploreClient({ initialEvents, initialTotal, + initialNextOffset, + initialRemaining, initialAsOf, + initialSort, + sortFromUrl, savedEvents, friendsEvents, initialSearch = "", - userName = "there", + greetingName, userAvatarUrl, }: ExploreClientProps) { /* @@ -51,6 +72,11 @@ export function ExploreClient({ * render on first load, which is precisely the case this screen has to handle. */ const [events, setEvents] = useState(initialEvents); + /** Latest `events`, for callbacks that outlive a render (the hide toast's Undo). */ + const eventsRef = useRef(events); + useEffect(() => { + eventsRef.current = events; + }, [events]); /* * The full match count, not the page size. `getFeedEvents` pages at 20 while * returning a separate count over every match, so reporting `events.length` @@ -58,12 +84,16 @@ export function ExploreClient({ */ const [total, setTotal] = useState(initialTotal); /* - * Pagination. `nextOffset` counts rows the server has handed us (not - * `events.length`, which is after de-duplication), and `asOf` pins later - * pages to the ranking page 1 came from. + * Pagination. `nextOffset` is the server's position in its ordering (not + * `events.length`, which is after de-duplication and hidden orgs), and + * `asOf` pins later pages to the ordering page 1 came from. */ - const [nextOffset, setNextOffset] = useState(initialEvents.length); + const [nextOffset, setNextOffset] = useState(initialNextOffset); + const [remaining, setRemaining] = useState(initialRemaining); const [asOf, setAsOf] = useState(initialAsOf); + const [sort, setSort] = useState(initialSort); + /** Orgs hidden from this screen — their cards drop out at once, Undo brings them back. */ + const [hiddenOrgIds, setHiddenOrgIds] = useState>(new Set()); const [isLoadingMore, setIsLoadingMore] = useState(false); const [activeFilters, setActiveFilters] = useState([]); /* @@ -83,10 +113,11 @@ export function ExploreClient({ * and without this the slower-but-older response lands last and wins. */ const latestRequest = useRef(0); - /** Filters + search the current list was fetched with; "Load more" must reuse them. */ - const currentQuery = useRef<{ filters: string[]; search: string }>({ + /** Filters, search and sort the current list was fetched with; "Load more" must reuse them. */ + const currentQuery = useRef<{ filters: string[]; search: string; sort: FeedSort }>({ filters: [], search: initialSearch.trim(), + sort: initialSort, }); /** Drop a queued debounced search — it carries whatever filters were active when it was armed. */ @@ -97,20 +128,22 @@ export function ExploreClient({ } }, []); - const refreshEvents = useCallback((filters: string[], search: string) => { + const refreshEvents = useCallback((filters: string[], search: string, sort: FeedSort) => { const requestId = ++latestRequest.current; - currentQuery.current = { filters, search }; + currentQuery.current = { filters, search, sort }; setIsLoadingMore(false); startTransition(async () => { try { const result = await getFeedEvents({ tags: filters.length > 0 ? filters : undefined, search: search || undefined, + sort, }); if (requestId !== latestRequest.current) return; setEvents(result.events); setTotal(result.total); - setNextOffset(result.events.length); + setNextOffset(result.nextOffset); + setRemaining(result.remaining); setAsOf(result.asOf); setLoadError(false); } catch { @@ -130,12 +163,13 @@ export function ExploreClient({ */ const loadMore = useCallback(async () => { const requestId = latestRequest.current; - const { filters, search } = currentQuery.current; + const { filters, search, sort } = currentQuery.current; setIsLoadingMore(true); try { const result = await getFeedEvents({ tags: filters.length > 0 ? filters : undefined, search: search || undefined, + sort, offset: nextOffset, asOf, }); @@ -145,7 +179,8 @@ export function ExploreClient({ return [...prev, ...result.events.filter((e) => !seen.has(e.id))]; }); setTotal(result.total); - setNextOffset(nextOffset + result.events.length); + setNextOffset(result.nextOffset); + setRemaining(result.remaining); setAsOf(result.asOf); } catch { if (requestId !== latestRequest.current) return; @@ -155,7 +190,99 @@ export function ExploreClient({ } }, [nextOffset, asOf]); - const hasMore = nextOffset < total; + const hasMore = remaining > 0; + + /* + * After hiding (or un-hiding) an org, re-read the counts for the ordering + * we're paging through — same `asOf` and offset — so "N upcoming events" + * and "Load more (N left)" stay true. The org's cards are already gone. + */ + const refreshCounts = useCallback(async () => { + const requestId = latestRequest.current; + const { filters, search, sort } = currentQuery.current; + try { + const result = await getFeedEvents({ + tags: filters.length > 0 ? filters : undefined, + search: search || undefined, + sort, + offset: nextOffset, + limit: 1, + asOf, + }); + if (requestId !== latestRequest.current) return; + setTotal(result.total); + setRemaining(result.remaining + result.events.length); + } catch { + // Counts are cosmetic; the next page load corrects them. + } + }, [nextOffset, asOf]); + + const visibleEvents = useMemo( + () => + hiddenOrgIds.size === 0 + ? events + : events.filter((e) => !e.orgId || !hiddenOrgIds.has(e.orgId)), + [events, hiddenOrgIds], + ); + + const setFollowingOrg = useCallback((orgId: string, following: boolean) => { + setEvents((prev) => + prev.map((e) => (e.orgId === orgId ? { ...e, isFollowingOrg: following } : e)), + ); + }, []); + + /* Optimistic across every card from the org, then reconciled. */ + const handleToggleFollowOrg = useCallback( + async (orgId: string, orgName: string, wasFollowing: boolean) => { + setFollowingOrg(orgId, !wasFollowing); + try { + const { following } = await toggleFollowOrg(orgId); + setFollowingOrg(orgId, following); + toast( + following + ? `Following ${orgName}. Their events will rank higher.` + : `Unfollowed ${orgName}`, + ); + } catch { + setFollowingOrg(orgId, wasFollowing); + toast.error("Couldn't update that follow. Please try again."); + } + }, + [setFollowingOrg], + ); + + const handleHideOrg = useCallback( + (orgId: string, orgName: string) => { + const showAgain = () => { + setHiddenOrgIds((prev) => { + const next = new Set(prev); + next.delete(orgId); + return next; + }); + }; + void hideOrgWithUndo({ + orgId, + orgName, + onHidden: () => { + setHiddenOrgIds((prev) => new Set(prev).add(orgId)); + setFollowingOrg(orgId, false); + }, + onSaved: () => void refreshCounts(), + onRestored: () => { + showAgain(); + // If the list was re-fetched while hidden, the org's events aren't + // loaded any more — fetch again rather than leave them missing. + if (eventsRef.current.some((e) => e.orgId === orgId)) void refreshCounts(); + else { + const { filters, search, sort } = currentQuery.current; + refreshEvents(filters, search, sort); + } + }, + onFollowRestored: () => setFollowingOrg(orgId, true), + }); + }, + [refreshCounts, refreshEvents, setFollowingOrg], + ); // A queued search outliving the component would fetch for a dead screen. useEffect(() => cancelPendingSearch, [cancelPendingSearch]); @@ -174,11 +301,50 @@ export function ExploreClient({ * already carries the current query, so nothing is lost by dropping it. */ cancelPendingSearch(); - refreshEvents(next, searchQuery.trim()); + refreshEvents(next, searchQuery.trim(), sort); + }, + [activeFilters, searchQuery, sort, refreshEvents, cancelPendingSearch], + ); + + /* + * Sort: kept in the URL (`?sort=`, omitted for the default) so it survives + * reloads and can be shared, and remembered per browser for visits that + * arrive without one. Storage can be unavailable, so every access is guarded. + */ + const applySort = useCallback( + (next: FeedSort, remember: boolean) => { + setSort(next); + if (remember) { + try { + window.localStorage.setItem(SORT_STORAGE_KEY, next); + } catch { + // ignore — the choice just won't persist + } + } + const url = new URL(window.location.href); + if (next === DEFAULT_FEED_SORT) url.searchParams.delete("sort"); + else url.searchParams.set("sort", next); + window.history.replaceState(window.history.state, "", url); + cancelPendingSearch(); + refreshEvents(activeFilters, searchQuery.trim(), next); }, [activeFilters, searchQuery, refreshEvents, cancelPendingSearch], ); + // A visit without `?sort=` picks up the sort this browser last used. + const restoredSort = useRef(false); + useEffect(() => { + if (restoredSort.current || sortFromUrl) return; + restoredSort.current = true; + let stored: string | null = null; + try { + stored = window.localStorage.getItem(SORT_STORAGE_KEY); + } catch { + return; + } + if (isFeedSort(stored) && stored !== initialSort) applySort(stored, false); + }, [sortFromUrl, initialSort, applySort]); + /* * Debounced as you type, matching Orgs. Explore used to require Enter, so * the two search fields behaved differently for no reason. @@ -188,10 +354,10 @@ export function ExploreClient({ setSearchQuery(value); if (searchTimeout.current) clearTimeout(searchTimeout.current); searchTimeout.current = setTimeout(() => { - refreshEvents(activeFilters, value.trim()); + refreshEvents(activeFilters, value.trim(), sort); }, 300); }, - [activeFilters, refreshEvents], + [activeFilters, sort, refreshEvents], ); /* @@ -267,7 +433,7 @@ export function ExploreClient({ */ return ( - +
- +
+ applySort(next, true)} /> + +
{loadError ? ( @@ -301,12 +470,12 @@ export function ExploreClient({ description="Something went wrong fetching the feed." onRetry={() => { cancelPendingSearch(); - refreshEvents(activeFilters, searchQuery.trim()); + refreshEvents(activeFilters, searchQuery.trim(), sort); }} /> - ) : isPending && events.length === 0 ? ( + ) : isPending && visibleEvents.length === 0 ? ( - ) : events.length === 0 ? ( + ) : visibleEvents.length === 0 ? ( ) : ( ( @@ -345,6 +514,22 @@ export function ExploreClient({ navigator.clipboard.writeText(`${window.location.origin}/events/${event.id}`); toast.success("Link copied to clipboard"); }} + isFollowingOrg={event.isFollowingOrg} + onToggleFollowOrg={ + event.orgId && event.orgName + ? () => + handleToggleFollowOrg( + event.orgId as string, + event.orgName as string, + Boolean(event.isFollowingOrg), + ) + : undefined + } + onHideOrg={ + event.orgId && event.orgName + ? () => handleHideOrg(event.orgId as string, event.orgName as string) + : undefined + } isHidden={hiddenIds.has(event.id)} onHide={() => { setHiddenIds((prev) => new Set(prev).add(event.id)); @@ -361,7 +546,7 @@ export function ExploreClient({ /> )} - {hasMore && !loadError && events.length > 0 && ( + {hasMore && !loadError && visibleEvents.length > 0 && (
)} diff --git a/apps/web/src/app/(app)/explore/page.tsx b/apps/web/src/app/(app)/explore/page.tsx index 40cb922..2d1ee65 100644 --- a/apps/web/src/app/(app)/explore/page.tsx +++ b/apps/web/src/app/(app)/explore/page.tsx @@ -1,30 +1,41 @@ import { getFeedEvents, getFriendsEvents, getSavedEvents } from "~/actions/events"; +import { getGreetingName } from "~/actions/users"; import { auth } from "~/auth"; +import { DEFAULT_FEED_SORT } from "~/lib/feed-ranking"; +import { feedSortSchema } from "~/lib/validation"; import { ExploreClient } from "./explore-client"; export default async function ExplorePage({ searchParams, }: { - searchParams: Promise<{ search?: string }>; + searchParams: Promise<{ search?: string; sort?: string | string[] }>; }) { - const { search } = await searchParams; + const { search, sort: sortParam } = await searchParams; + // An unknown or repeated `?sort=` falls back to the default rather than erroring. + const parsedSort = feedSortSchema.safeParse(sortParam); + const sort = parsedSort.success ? parsedSort.data : DEFAULT_FEED_SORT; - const [feedResult, savedEvents, friendsEvents, session] = await Promise.all([ - getFeedEvents(search ? { search } : undefined), + const [feedResult, savedEvents, friendsEvents, session, greetingName] = await Promise.all([ + getFeedEvents({ search: search || undefined, sort }), getSavedEvents(), getFriendsEvents(), auth(), + getGreetingName(), ]); return ( ); diff --git a/apps/web/src/app/(app)/orgs/[id]/org-profile-client.tsx b/apps/web/src/app/(app)/orgs/[id]/org-profile-client.tsx index 860f9a1..d11b887 100644 --- a/apps/web/src/app/(app)/orgs/[id]/org-profile-client.tsx +++ b/apps/web/src/app/(app)/orgs/[id]/org-profile-client.tsx @@ -6,8 +6,11 @@ import { ChevronLeft, Copy, ExternalLink, + Eye, + EyeOff, Globe, Heart, + MoreHorizontal, Shield, Users, X, @@ -17,7 +20,14 @@ import { useRouter } from "next/navigation"; import { useCallback, useState, useTransition } from "react"; import { toast } from "sonner"; import { type UserSearchResult, searchUsers } from "~/actions/friends"; -import { type OrgDetail, addOfficer, removeOfficer, toggleFollowOrg } from "~/actions/orgs"; +import { + type OrgDetail, + addOfficer, + blockOrg, + removeOfficer, + toggleFollowOrg, + unblockOrg, +} from "~/actions/orgs"; import { OrgAvatar } from "~/components/common/org-avatar"; import { RichText } from "~/components/common/rich-text"; import { SearchInput } from "~/components/common/search-input"; @@ -26,6 +36,12 @@ import { EventCard } from "~/components/events/event-card"; import { PageShell } from "~/components/layout/page-shell"; import { OrgEmails } from "~/components/orgs/org-emails"; import { Button } from "~/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu"; import type { OrgEmails as OrgEmailsData } from "~/lib/inbox-engine"; const SOCIAL_LABELS: Record = { @@ -86,15 +102,19 @@ export function OrgProfileClient({ const router = useRouter(); const [isPending, startTransition] = useTransition(); const [isFollowing, setIsFollowing] = useState(org.isFollowing); + const [isHidden, setIsHidden] = useState(org.isHidden); const [followerCount, setFollowerCount] = useState(org.followerCount); const [team, setTeam] = useState(org.team); const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState([]); const official = org.source === "myprincetonu"; + /* Following also unhides (the two are mutually exclusive). */ const handleToggleFollow = () => { const next = !isFollowing; + const wasHidden = isHidden; setIsFollowing(next); + if (next) setIsHidden(false); setFollowerCount((c) => c + (next ? 1 : -1)); startTransition(async () => { try { @@ -102,12 +122,41 @@ export function OrgProfileClient({ setIsFollowing(result.following); } catch { setIsFollowing(!next); + setIsHidden(wasHidden); setFollowerCount((c) => c + (next ? -1 : 1)); toast.error("Couldn't update follow. Try again."); } }); }; + /* Hiding also unfollows; showing again doesn't re-follow. */ + const handleSetHidden = (hide: boolean) => { + const wasFollowing = isFollowing; + setIsHidden(hide); + if (hide && wasFollowing) { + setIsFollowing(false); + setFollowerCount((c) => c - 1); + } + startTransition(async () => { + try { + if (hide) await blockOrg(org.id); + else await unblockOrg(org.id); + toast( + hide + ? `${org.name}'s events are hidden from your feed` + : `${org.name}'s events will show in your feed again`, + ); + } catch { + setIsHidden(!hide); + if (hide && wasFollowing) { + setIsFollowing(true); + setFollowerCount((c) => c + 1); + } + toast.error("Couldn't update that. Please try again."); + } + }); + }; + const handleSearch = useCallback( async (query: string) => { setSearchQuery(query); @@ -287,18 +336,63 @@ export function OrgProfileClient({ )} - +
+ + + + + + + {isHidden ? ( + handleSetHidden(false)}> + + Show in my feed + + ) : ( + handleSetHidden(true)}> + + Hide from my feed + + )} + + +
+ {isHidden && ( +

+ + You've hidden this organization's events + + · + + +

+ )} +
{org.description && ( diff --git a/apps/web/src/app/(app)/orgs/orgs-client.tsx b/apps/web/src/app/(app)/orgs/orgs-client.tsx index b8d5d11..f14111b 100644 --- a/apps/web/src/app/(app)/orgs/orgs-client.tsx +++ b/apps/web/src/app/(app)/orgs/orgs-client.tsx @@ -1,15 +1,22 @@ "use client"; -import { Check, Plus, Users } from "lucide-react"; +import { Check, Eye, EyeOff, Plus, Users } from "lucide-react"; import Link from "next/link"; -import { useCallback, useRef, useState, useTransition } from "react"; +import { useCallback, useEffect, useRef, useState, useTransition } from "react"; import { toast } from "sonner"; -import { type OrgListItem, getOrgs, toggleFollowOrg } from "~/actions/orgs"; +import { + type HiddenOrg, + type OrgListItem, + getOrgs, + toggleFollowOrg, + unblockOrg, +} from "~/actions/orgs"; import { FilterChip, FilterChipGroup } from "~/components/common/filter-chip"; import { OrgAvatar } from "~/components/common/org-avatar"; import { SearchInput } from "~/components/common/search-input"; import { EmptyState } from "~/components/common/states"; import { Button } from "~/components/ui/button"; +import { formatTimeAgo } from "~/lib/date-format"; import { cn } from "~/lib/utils"; const ORG_CATEGORIES = [ @@ -26,16 +33,42 @@ const ORG_CATEGORIES = [ { id: "community service", label: "Service" }, ]; +/** Pseudo-category: the orgs the viewer hid from their feed. */ +const HIDDEN = "hidden"; + interface OrgsClientProps { initialOrgs: OrgListItem[]; recommendedOrgs: OrgListItem[]; + /** Orgs the viewer hid, most recent first. */ + initialHidden: HiddenOrg[]; + /** Open on the Hidden filter (`/orgs?view=hidden`). */ + showHidden?: boolean; } -export function OrgsClient({ initialOrgs, recommendedOrgs }: OrgsClientProps) { +export function OrgsClient({ + initialOrgs, + recommendedOrgs, + initialHidden, + showHidden = false, +}: OrgsClientProps) { const [isPending, startTransition] = useTransition(); const [orgs, setOrgs] = useState(initialOrgs); + const [hidden, setHidden] = useState(initialHidden); const [searchQuery, setSearchQuery] = useState(""); - const [activeCategory, setActiveCategory] = useState("all"); + const [activeCategory, setActiveCategory] = useState( + showHidden && initialHidden.length > 0 ? HIDDEN : "all", + ); + const viewingHidden = activeCategory === HIDDEN; + const hiddenChipRef = useRef(null); + + /* + * Arriving on /orgs?view=hidden: scroll the chip row (not the page — which + * scrollIntoView would also move) to its end, where the Hidden chip sits. + */ + useEffect(() => { + const row = hiddenChipRef.current?.parentElement; + if (showHidden && row) row.scrollLeft = row.scrollWidth; + }, [showHidden]); const searchTimeout = useRef>(null); const refreshOrgs = useCallback((search?: string, category?: string) => { @@ -51,14 +84,32 @@ export function OrgsClient({ initialOrgs, recommendedOrgs }: OrgsClientProps) { const handleSearch = (query: string) => { setSearchQuery(query); if (searchTimeout.current) clearTimeout(searchTimeout.current); + // Searching always searches every org — leave the Hidden list for it. + const category = viewingHidden ? "all" : activeCategory; + if (viewingHidden) setActiveCategory("all"); searchTimeout.current = setTimeout(() => { - refreshOrgs(query, activeCategory); + refreshOrgs(query, category); }, 300); }; const handleCategoryChange = (cat: string) => { setActiveCategory(cat); - refreshOrgs(searchQuery, cat); + if (cat !== HIDDEN) refreshOrgs(searchQuery, cat); + }; + + const handleUnhide = (org: HiddenOrg) => { + setHidden((prev) => prev.filter((o) => o.id !== org.id)); + setOrgs((prev) => prev.map((o) => (o.id === org.id ? { ...o, isHidden: false } : o))); + startTransition(async () => { + try { + await unblockOrg(org.id); + toast(`Events from ${org.name} will show in your feed again`); + } catch { + setHidden((prev) => [org, ...prev]); + setOrgs((prev) => prev.map((o) => (o.id === org.id ? { ...o, isHidden: true } : o))); + toast.error("Couldn't unhide that organization. Please try again."); + } + }); }; const handleToggleFollow = (orgId: string) => { @@ -70,12 +121,20 @@ export function OrgsClient({ initialOrgs, recommendedOrgs }: OrgsClientProps) { followerCount: o.isFollowing ? o.followerCount - 1 : o.followerCount + 1, } : o; + const before = orgs.find((o) => o.id === orgId); + const hiddenBefore = hidden; setOrgs((prev) => prev.map(flip)); + // Following unhides. + if (before && !before.isFollowing && before.isHidden) { + setOrgs((prev) => prev.map((o) => (o.id === orgId ? { ...o, isHidden: false } : o))); + setHidden((prev) => prev.filter((o) => o.id !== orgId)); + } startTransition(async () => { try { await toggleFollowOrg(orgId); } catch { - setOrgs((prev) => prev.map(flip)); + setOrgs((prev) => prev.map((o) => (o.id === orgId && before ? { ...before } : o))); + setHidden(hiddenBefore); toast.error("Couldn't update that follow. Please try again."); } }); @@ -111,83 +170,165 @@ export function OrgsClient({ initialOrgs, recommendedOrgs }: OrgsClientProps) { {label} ))} + {/* Only once something is hidden — or while viewing the list, so unhiding the last one doesn't yank it away. */} + {(hidden.length > 0 || viewingHidden) && ( + handleCategoryChange(HIDDEN)} + > + + Hidden + {hidden.length} + + )} - {recommendedOrgs.length > 0 && ( -

- Suggested - {recommendedOrgs.map((org, i) => ( - - - {org.name} - - {i < recommendedOrgs.length - 1 && · } - - ))} -

- )} - - {orgs.length > 0 ? ( -
    - {orgs.map((org) => { - return ( -
  • - -
    + {viewingHidden ? ( + + ) : ( + <> + {recommendedOrgs.length > 0 && ( +

    + + Suggested + + {recommendedOrgs.map((org, i) => ( + {org.name} -

    - {org.category} · {org.followerCount}{" "} - {org.followerCount === 1 ? "follower" : "followers"} - {org.description && ( - · {org.description} - )} -

    -
    - -
  • - ); - })} -
- ) : ( - + {i < recommendedOrgs.length - 1 && · } + + ))} +

+ )} + + {orgs.length > 0 ? ( +
    + {orgs.map((org) => { + return ( +
  • + +
    + + {org.name} + +

    + {org.isHidden && ( + Hidden · + )} + {org.category} · {org.followerCount}{" "} + {org.followerCount === 1 ? "follower" : "followers"} + {org.description && ( + · {org.description} + )} +

    +
    + +
  • + ); + })} +
+ ) : ( + + )} + )}
); } + +/** The Hidden filter: orgs whose events you removed from your feed, with Unhide. */ +function HiddenOrgList({ + orgs, + onUnhide, +}: { + orgs: HiddenOrg[]; + onUnhide: (org: HiddenOrg) => void; +}) { + if (orgs.length === 0) { + return ( + + ); + } + return ( + <> +

+ Their events are left out of Home, the map and suggestions. Their pages and event links + still work. +

+
    + {orgs.map((org) => ( +
  • + +
    + + {org.name} + +

    + {org.category} · hidden{" "} + {formatTimeAgo(new Date(org.hiddenAt))} +

    +
    + +
  • + ))} +
+ + ); +} diff --git a/apps/web/src/app/(app)/orgs/page.tsx b/apps/web/src/app/(app)/orgs/page.tsx index 4b5d0c8..5bfa778 100644 --- a/apps/web/src/app/(app)/orgs/page.tsx +++ b/apps/web/src/app/(app)/orgs/page.tsx @@ -1,17 +1,31 @@ import type { Metadata } from "next"; -import { getOrgs, getRecommendedOrgs } from "~/actions/orgs"; +import { getBlockedOrgs, getOrgs, getRecommendedOrgs } from "~/actions/orgs"; import { PageHeading, PageShell } from "~/components/layout/page-shell"; import { OrgsClient } from "./orgs-client"; export const metadata: Metadata = { title: "Organizations" }; -export default async function OrgsPage() { - const [orgs, recommended] = await Promise.all([getOrgs(), getRecommendedOrgs()]); +export default async function OrgsPage({ + searchParams, +}: { + searchParams: Promise<{ view?: string | string[] }>; +}) { + const { view } = await searchParams; + const [orgs, recommended, hidden] = await Promise.all([ + getOrgs(), + getRecommendedOrgs(), + getBlockedOrgs(), + ]); return ( Organizations - + ); } diff --git a/apps/web/src/app/(onboarding)/onboarding/onboarding-client.tsx b/apps/web/src/app/(onboarding)/onboarding/onboarding-client.tsx index e3c757f..c8d7835 100644 --- a/apps/web/src/app/(onboarding)/onboarding/onboarding-client.tsx +++ b/apps/web/src/app/(onboarding)/onboarding/onboarding-client.tsx @@ -3,6 +3,7 @@ import { useMemo, useState, useTransition } from "react"; import { toast } from "sonner"; import { completeOnboarding } from "~/actions/users"; +import { greetingName } from "~/lib/greeting-name"; import { PRINCETON_MAJORS } from "~/lib/princeton-departments"; import { CAMPUS_REGION_OPTIONS, @@ -437,9 +438,16 @@ export function OnboardingClient({ displayName, netId, email }: OnboardingClient {step === 4 && (

- You are all set -
- {firstName.trim() || "there"}! + {/* Same rule as Home's greeting: no real name, no name at all. */} + {greetingName(firstName, netId) ? ( + <> + You are all set +
+ {greetingName(firstName, netId)}! + + ) : ( + "You are all set!" + )}

Your personalized Forum feed is ready. diff --git a/apps/web/src/components/common/filter-chip.tsx b/apps/web/src/components/common/filter-chip.tsx index 1523949..d66b8d2 100644 --- a/apps/web/src/components/common/filter-chip.tsx +++ b/apps/web/src/components/common/filter-chip.tsx @@ -73,7 +73,12 @@ export function FilterChipGroup({ return (

{label} diff --git a/apps/web/src/components/events/event-card.tsx b/apps/web/src/components/events/event-card.tsx index e723e92..644ab77 100644 --- a/apps/web/src/components/events/event-card.tsx +++ b/apps/web/src/components/events/event-card.tsx @@ -7,7 +7,6 @@ import { Clock, Edit3, Eye, - EyeOff, MapPin, Maximize2, Plus, @@ -20,6 +19,7 @@ import { toast } from "sonner"; import { logInteraction } from "~/actions/interactions"; import { OrgAvatar } from "~/components/common/org-avatar"; import { AttendeesDialog } from "~/components/events/attendees-dialog"; +import { EventActionsMenu } from "~/components/orgs/org-feed-menu"; import { AvatarStack } from "~/components/social/avatar-stack"; import { Button } from "~/components/ui/button"; import { buildGCalUrl } from "~/lib/calendar"; @@ -91,12 +91,21 @@ export interface EventCardProps { onSaveToggle?: () => void | Promise; onRsvpToggle?: () => void | Promise; onShare?: () => void; + /** "Not interested in this event", in the card's "⋯" menu. */ onHide?: () => void; /** When true the card collapses to a stub that can be restored. */ isHidden?: boolean; onUnhide?: () => void; /** Extra action, e.g. the map's "Show on map". */ onLocate?: () => void; + /** + * Host-org actions, in the "⋯" menu: follow/unfollow (hoists the org in the + * feed) and hide its events. The menu renders only when the event has an org + * and both handlers are supplied. + */ + isFollowingOrg?: boolean; + onToggleFollowOrg?: () => void; + onHideOrg?: () => void; /** * Open the event in place instead of navigating to its page. The map uses * this so opening a card doesn't throw you off the map. @@ -157,6 +166,9 @@ export function EventCard({ isHidden = false, onUnhide, onLocate, + isFollowingOrg = false, + onToggleFollowOrg, + onHideOrg, onOpen, density = "default", calendarUrl, @@ -224,7 +236,48 @@ export function EventCard({ logInteraction({ itemId: id, interactionType: "click", metadata: { source, position } }); }; - const hasUtilityRow = Boolean(onSaveToggle || onShare || onHide); + /* + * "Not interested": the per-event hide. The card collapses to a stub with + * Unhide, and the toast offers the same Undo. + */ + const handleNotInterested = onHide + ? () => { + logInteraction({ itemId: id, interactionType: "hide", metadata: { source, position } }); + onHide(); + toast(`Hid ${title}`, { + description: "We'll show you fewer events like this.", + action: onUnhide ? { label: "Undo", onClick: onUnhide } : undefined, + }); + } + : undefined; + + const orgActions = + orgId && orgName && onToggleFollowOrg && onHideOrg + ? { + name: orgName, + isFollowing: isFollowingOrg, + onToggleFollow: onToggleFollowOrg, + onHide: onHideOrg, + } + : null; + + /* + * The single "⋯" menu: "Not interested", then Follow / Hide the host org. + * Present on every feed card (org-less events get "Not interested" alone), + * so the top row reads the same on every card. + */ + const actionsMenu = (className: string, iconClassName?: string) => ( + + ); + + const hasActionsMenu = Boolean(handleNotInterested || orgActions); + const hasUtilityRow = Boolean(onSaveToggle || onShare || hasActionsMenu); /* * Hidden events collapse to a stub rather than disappearing. Removing the @@ -349,27 +402,6 @@ export function EventCard({
- {onHide && ( - - )} {onShare && ( )} + {actionsMenu(utilityButton)} {editHref && ( )} - {/* Hide: revealed on hover/focus on desktop so the resting card shows the - Figma's two icons; always visible on touch. */} - {onHide && ( - - )}
- +
+ {actionsMenu(UTILITY_HOVER, "text-forum-coral")} + +
)} diff --git a/apps/web/src/components/events/feed-sort-menu.tsx b/apps/web/src/components/events/feed-sort-menu.tsx new file mode 100644 index 0000000..8edbd57 --- /dev/null +++ b/apps/web/src/components/events/feed-sort-menu.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { ArrowUpDown, ChevronDown } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu"; +import { FEED_SORTS, type FeedSort } from "~/lib/feed-ranking"; +import { cn } from "~/lib/utils"; + +export const FEED_SORT_LABELS: Record = { + foryou: { label: "For you", short: "For you", hint: "Ranked for you" }, + soonest: { label: "Soonest", short: "Soonest", hint: "Starting soonest first" }, + recent: { label: "Recently posted", short: "Recent", hint: "Newest announcements first" }, +}; + +/** Compact sort picker that sits beside the Cards/List toggle on Home. */ +export function FeedSortMenu({ + sort, + onChange, + className, +}: { + sort: FeedSort; + onChange: (sort: FeedSort) => void; + className?: string; +}) { + const current = FEED_SORT_LABELS[sort]; + return ( + + + + {current.short} + {current.label} + + + + onChange(v as FeedSort)}> + {FEED_SORTS.map((id) => ( + + {FEED_SORT_LABELS[id].label} + {FEED_SORT_LABELS[id].hint} + + ))} + + + + ); +} diff --git a/apps/web/src/components/layout/greeting.tsx b/apps/web/src/components/layout/greeting.tsx index a54f5d6..16ec747 100644 --- a/apps/web/src/components/layout/greeting.tsx +++ b/apps/web/src/components/layout/greeting.tsx @@ -3,14 +3,20 @@ import { formatGreetingDate } from "~/lib/date-format"; /** * Home's greeting from the Figma: "Hello **Albert**," in large serif with the * name in bold italic, and today's date (Princeton time) under an orange dot. + * With no real name yet (see ~/lib/greeting-name) it is just "Hello,". * Only Home gets this; every other page uses the compact PageHeading. */ -export function Greeting({ name }: { name: string }) { - const first = name.trim().split(/\s+/)[0] || "there"; +export function Greeting({ name }: { name: string | null }) { return (

- Hello {first}, + {name ? ( + <> + Hello {name}, + + ) : ( + "Hello," + )}

diff --git a/apps/web/src/components/orgs/org-feed-menu.tsx b/apps/web/src/components/orgs/org-feed-menu.tsx new file mode 100644 index 0000000..569a025 --- /dev/null +++ b/apps/web/src/components/orgs/org-feed-menu.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { Eye, EyeOff, Heart, HeartOff, MoreHorizontal, ThumbsDown } from "lucide-react"; +import { toast } from "sonner"; +import { blockOrg, unblockOrg } from "~/actions/orgs"; +import { Button } from "~/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu"; + +/** + * The one "⋯" menu for an event, on feed cards and the event page: + * 1. "Not interested in this event" — the per-event hide (cards only); + * 2. Follow / Unfollow — hoists the org in the feed; + * 3. Hide events from — removes the org from discovery. + * Org items appear only when the event has an org, so an org-less card's menu + * holds just "Not interested". Renders nothing when there is nothing to offer. + */ +export function EventActionsMenu({ + eventTitle, + onNotInterested, + org, + variant = "ghost", + className, + iconClassName, +}: { + eventTitle: string; + /** Per-event hide; omitted where events can't be hidden (the event page). */ + onNotInterested?: () => void; + org?: { + name: string; + isFollowing: boolean; + /** Already hidden (the event page only — hidden orgs never reach a feed card). */ + isHidden?: boolean; + onToggleFollow: () => void; + onHide: () => void; + onUnhide?: () => void; + } | null; + /** `outline` beside the event page's other outline buttons; `ghost` on cards. */ + variant?: "ghost" | "outline"; + className?: string; + iconClassName?: string; +}) { + if (!onNotInterested && !org) return null; + return ( + + + + + + {onNotInterested && ( + + + Not interested in this event + + )} + {onNotInterested && org && } + {org && ( + <> + + {org.isFollowing ? : } + + {org.isFollowing ? "Unfollow" : "Follow"} {org.name} + + + {org.isHidden && org.onUnhide ? ( + + + Show events from {org.name} + + ) : ( + + + Hide events from {org.name} + + )} + + )} + + + ); +} + +/** + * Hide an org, then offer Undo in a toast. `onHidden` runs immediately + * (optimistically) and `onSaved` once the server has it; `onRestored` runs if + * the request fails or Undo succeeds. Undo restores a follow the hide removed. + */ +export async function hideOrgWithUndo({ + orgId, + orgName, + onHidden, + onSaved, + onRestored, + onFollowRestored, +}: { + orgId: string; + orgName: string; + onHidden: () => void; + onSaved?: () => void; + onRestored: () => void; + /** Undo re-followed the org (the hide had unfollowed it). */ + onFollowRestored?: () => void; +}) { + onHidden(); + let unfollowed = false; + try { + ({ unfollowed } = await blockOrg(orgId)); + } catch { + onRestored(); + toast.error(`Couldn't hide ${orgName}. Please try again.`); + return; + } + onSaved?.(); + toast(`Hidden events from ${orgName}`, { + description: "Manage hidden organizations on the Orgs page.", + action: { + label: "Undo", + onClick: async () => { + try { + await unblockOrg(orgId, { refollow: unfollowed }); + onRestored(); + if (unfollowed) onFollowRestored?.(); + } catch { + toast.error(`Couldn't undo. Try “Show in my feed” on ${orgName}'s page.`); + } + }, + }, + }); +} diff --git a/apps/web/src/lib/event-visibility.ts b/apps/web/src/lib/event-visibility.ts index 0a2892b..0e42ee5 100644 --- a/apps/web/src/lib/event-visibility.ts +++ b/apps/web/src/lib/event-visibility.ts @@ -1,4 +1,16 @@ -import { events, and, db, eq, inArray, or, orgMembers } from "@the-forum/database"; +import { + events, + and, + db, + eq, + inArray, + isNull, + lte, + notInArray, + or, + orgBlocks, + orgMembers, +} from "@the-forum/database"; /** * Event visibility — the ONE place this rule is defined. Server-only. @@ -39,6 +51,7 @@ export function eventVisibleTo(viewerId: string) { * friends' activity): published AND visible. Drafts never appear in discovery, * even to their authors — they live in My Events and on the org page. * Private published events still show for their creator and org managers. + * Discovery surfaces also apply `notFromHiddenOrg`, below. */ export function eventDiscoverableBy(viewerId: string) { const discoverable = and(eq(events.status, "published"), eventVisibleTo(viewerId)); @@ -46,6 +59,39 @@ export function eventDiscoverableBy(viewerId: string) { return discoverable; } +/** Subquery: ids of orgs the viewer has hidden, optionally only those hidden by `asOf`. */ +export function hiddenOrgIdsQuery(viewerId: string, asOf?: Date) { + return db + .select({ orgId: orgBlocks.orgId }) + .from(orgBlocks) + .where( + asOf + ? and(eq(orgBlocks.userId, viewerId), lte(orgBlocks.createdAt, asOf)) + : eq(orgBlocks.userId, viewerId), + ); +} + +/** + * WHERE fragment for discovery surfaces: the event is not hosted by an org the + * viewer hid ("Hide events from "). Events without an org always pass. + * + * Applied to Explore (every sort, and its search), the map, similar events and + * friends' activity. Deliberately NOT applied to the org's own page, direct + * event links, or the viewer's own RSVPs/saves — hiding an org filters + * discovery; it doesn't make its events unreachable. + * + * `asOf` limits it to orgs hidden by then, so a paginated feed keeps slicing + * the ranking page 1 came from (see docs/ranking.md, "Pagination"). + */ +export function notFromHiddenOrg(viewerId: string, asOf?: Date) { + const clause = or( + isNull(events.orgId), + notInArray(events.orgId, hiddenOrgIdsQuery(viewerId, asOf)), + ); + if (!clause) throw new Error("unreachable: empty hidden-org predicate"); + return clause; +} + /** WHERE fragment: the viewer may edit the event (creator, or owner/officer of its org). */ export function eventEditableBy(viewerId: string) { const editable = or( diff --git a/apps/web/src/lib/feed-controls.db.test.ts b/apps/web/src/lib/feed-controls.db.test.ts new file mode 100644 index 0000000..dbdc5c4 --- /dev/null +++ b/apps/web/src/lib/feed-controls.db.test.ts @@ -0,0 +1,258 @@ +/** + * Feed controls against a real database: hidden orgs, the three sorts and + * stable pagination. Skipped unless FEED_TEST_DATABASE_URL points at a + * migrated database with upcoming events (e.g. after `db:sync-engine`): + * + * FEED_TEST_DATABASE_URL=postgres://… bun test src/lib/feed-controls.db.test.ts + * + * It creates one throwaway user and deletes it (cascading its follows and + * hides) at the end; event and org rows are only read. + */ +import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; + +const url = process.env.FEED_TEST_DATABASE_URL; +if (url) process.env.DATABASE_URL = url; + +const TEST_NET_ID = "_feedcontrols_test"; +let userId = ""; + +// Server actions (the map) read the session from ~/auth; stand in the test user. +if (url) { + mock.module("~/auth", () => ({ + auth: async () => ({ user: { id: userId } }), + })); +} + +describe.skipIf(!url)("feed controls (database)", () => { + let dbm: typeof import("@the-forum/database"); + let feed: typeof import("~/lib/feed"); + let prefs: typeof import("~/lib/org-preferences"); + let map: typeof import("~/actions/map"); + /** An org with several upcoming events in the next week, at least one mappable. */ + let orgId = ""; + + /** Every event of one sort, paging with `asOf` the way the client does. */ + async function allPages(sort: "foryou" | "soonest" | "recent", limit = 20) { + const ids: string[] = []; + let offset = 0; + let asOf: Date | undefined; + for (;;) { + const page = await feed.loadRankedFeed(userId, { sort, limit, offset, asOf }); + ids.push(...page.events.map((e) => e.id)); + asOf = new Date(page.asOf); + offset = page.nextOffset; + if (page.remaining === 0) return { ids, total: page.total }; + } + } + + async function orgEventIds(id: string) { + const rows = await dbm.db + .select({ id: dbm.events.id }) + .from(dbm.events) + .where(dbm.eq(dbm.events.orgId, id)); + return new Set(rows.map((r) => r.id)); + } + + beforeAll(async () => { + dbm = await import("@the-forum/database"); + feed = await import("~/lib/feed"); + prefs = await import("~/lib/org-preferences"); + map = await import("~/actions/map"); + + await dbm.db.delete(dbm.users).where(dbm.eq(dbm.users.netId, TEST_NET_ID)); + const [user] = await dbm.db + .insert(dbm.users) + .values({ + netId: TEST_NET_ID, + email: `${TEST_NET_ID}@princeton.edu`, + displayName: "Feed Test", + onboarded: true, + }) + .returning({ id: dbm.users.id }); + if (!user) throw new Error("could not create test user"); + userId = user.id; + + const [busiest] = await dbm.db + .select({ orgId: dbm.events.orgId, n: dbm.sql`count(*)::int` }) + .from(dbm.events) + .innerJoin(dbm.campusLocations, dbm.eq(dbm.events.locationId, dbm.campusLocations.id)) + .where( + dbm.and( + dbm.eq(dbm.events.status, "published"), + dbm.eq(dbm.events.isPublic, true), + dbm.isNotNull(dbm.events.orgId), + dbm.gt(dbm.events.datetime, new Date()), + dbm.lt(dbm.events.datetime, new Date(Date.now() + 6 * 86_400_000)), + dbm.ne(dbm.campusLocations.latitude, 0), + ), + ) + .groupBy(dbm.events.orgId) + .orderBy(dbm.desc(dbm.sql`count(*)`)) + .limit(1); + if (!busiest?.orgId) throw new Error("no upcoming org events — sync some data first"); + orgId = busiest.orgId; + }); + + afterAll(async () => { + if (userId) await dbm.db.delete(dbm.users).where(dbm.eq(dbm.users.id, userId)); + }); + + test("pagination: every sort covers the same events, once each", async () => { + const forYou = await allPages("foryou"); + const soonest = await allPages("soonest"); + const recent = await allPages("recent", 7); + for (const run of [forYou, soonest, recent]) { + expect(new Set(run.ids).size).toBe(run.ids.length); + expect(run.ids.length).toBe(run.total); + } + expect(new Set(soonest.ids)).toEqual(new Set(forYou.ids)); + expect(new Set(recent.ids)).toEqual(new Set(forYou.ids)); + }); + + test("For you: announced events lead; unannounced MyPrincetonU listings fill in below", async () => { + const { ids } = await allPages("foryou"); + const rows = await dbm.db + .select({ id: dbm.events.id, count: dbm.events.announcementCount }) + .from(dbm.events) + .where(dbm.inArray(dbm.events.id, ids)); + const announced = new Set(rows.filter((r) => r.count > 0).map((r) => r.id)); + const positions = ids.map((id, i) => (announced.has(id) ? i : -1)).filter((i) => i >= 0); + if (announced.size === 0) return; + const meanAnnounced = positions.reduce((a, b) => a + b, 0) / positions.length; + console.log( + ` announced events: ${announced.size}/${ids.length}; mean position ${meanAnnounced.toFixed(1)}; ` + + `in top 20: ${positions.filter((p) => p < 20).length}`, + ); + expect(meanAnnounced).toBeLessThan(ids.length / 4); + }); + + test("Recently posted: ordered by announcement time (else entry time), newest first", async () => { + const { ids } = await allPages("recent"); + const rows = await dbm.db + .select({ + id: dbm.events.id, + announcedAt: dbm.events.announcedAt, + createdAt: dbm.events.createdAt, + }) + .from(dbm.events) + .where(dbm.inArray(dbm.events.id, ids)); + const posted = new Map(rows.map((r) => [r.id, (r.announcedAt ?? r.createdAt).getTime()])); + for (let i = 1; i < ids.length; i++) { + expect(posted.get(ids[i] as string) as number).toBeLessThanOrEqual( + posted.get(ids[i - 1] as string) as number, + ); + } + }); + + test("hidden org: absent from every sort, search and the map; back after unhiding", async () => { + const hiddenIds = await orgEventIds(orgId); + const before = await allPages("soonest"); + const mapBefore = await map.getMapEvents({ days: 7 }); + expect(before.ids.some((id) => hiddenIds.has(id))).toBe(true); + expect(mapBefore.some((e) => hiddenIds.has(e.id))).toBe(true); + + await prefs.setHidden(userId, orgId); + + for (const sort of ["foryou", "soonest", "recent"] as const) { + const { ids } = await allPages(sort); + expect(ids.some((id) => hiddenIds.has(id))).toBe(false); + expect(ids.length).toBe( + before.ids.length - before.ids.filter((id) => hiddenIds.has(id)).length, + ); + } + const [orgRow] = await dbm.db + .select({ name: dbm.organizations.name }) + .from(dbm.organizations) + .where(dbm.eq(dbm.organizations.id, orgId)); + const [anyTitle] = await dbm.db + .select({ title: dbm.events.title }) + .from(dbm.events) + .where(dbm.inArray(dbm.events.id, [...hiddenIds])) + .limit(1); + const searched = await feed.loadRankedFeed(userId, { + search: anyTitle?.title.slice(0, 20), + limit: 50, + offset: 0, + }); + expect(searched.events.some((e) => hiddenIds.has(e.id))).toBe(false); + const mapAfter = await map.getMapEvents({ days: 7 }); + expect(mapAfter.some((e) => hiddenIds.has(e.id))).toBe(false); + console.log( + ` hid "${orgRow?.name}": ${hiddenIds.size} events (all dates) hidden; none in any sort, search or the map ` + + `(map ${mapBefore.length} → ${mapAfter.length})`, + ); + + await prefs.clearHidden(userId, orgId); + const restored = await allPages("soonest"); + expect(restored.ids).toEqual(before.ids); + }); + + test("hiding mid-scroll: later pages drop the org without shifting anything else", async () => { + const hiddenIds = await orgEventIds(orgId); + const full = await allPages("soonest", 10); + + const page1 = await feed.loadRankedFeed(userId, { sort: "soonest", limit: 10, offset: 0 }); + await prefs.setHidden(userId, orgId); + const rest: string[] = []; + let offset = page1.nextOffset; + for (;;) { + const page = await feed.loadRankedFeed(userId, { + sort: "soonest", + limit: 10, + offset, + asOf: new Date(page1.asOf), + }); + rest.push(...page.events.map((e) => e.id)); + offset = page.nextOffset; + if (page.remaining === 0) break; + } + await prefs.clearHidden(userId, orgId); + + const seen = [...page1.events.map((e) => e.id), ...rest]; + const expected = [ + ...full.ids.slice(0, page1.events.length), + ...full.ids.slice(page1.events.length).filter((id) => !hiddenIds.has(id)), + ]; + expect(seen).toEqual(expected); + }); + + test("hiding unfollows, and following unhides", async () => { + await prefs.setFollowing(userId, orgId); + expect(await prefs.setHidden(userId, orgId)).toEqual({ unfollowed: true }); + expect(await prefs.loadOrgViewerState(userId, orgId)).toEqual({ + following: false, + hidden: true, + }); + await prefs.setFollowing(userId, orgId); + expect(await prefs.loadOrgViewerState(userId, orgId)).toEqual({ + following: true, + hidden: false, + }); + // Idempotent both ways. + await prefs.setFollowing(userId, orgId); + await prefs.clearFollowing(userId, orgId); + await prefs.clearFollowing(userId, orgId); + expect(await prefs.loadOrgViewerState(userId, orgId)).toEqual({ + following: false, + hidden: false, + }); + }); + + test("following an org raises its events in For you", async () => { + const orgIds = await orgEventIds(orgId); + const positions = (ids: string[]) => + ids.map((id, i) => (orgIds.has(id) ? i : -1)).filter((i) => i >= 0); + + const before = positions((await allPages("foryou")).ids); + await prefs.setFollowing(userId, orgId); + const after = positions((await allPages("foryou")).ids); + await prefs.clearFollowing(userId, orgId); + + console.log(` org event positions: before [${before}] → followed [${after}]`); + expect(after.length).toBe(before.length); + expect(Math.min(...after)).toBeLessThanOrEqual(Math.min(...before)); + expect(after.reduce((a, b) => a + b, 0)).toBeLessThan(before.reduce((a, b) => a + b, 0)); + // The diversity cap still applies: at most 3 in the first 20. + expect(after.filter((p) => p < 20).length).toBeLessThanOrEqual(3); + }); +}); diff --git a/apps/web/src/lib/feed-ranking.test.ts b/apps/web/src/lib/feed-ranking.test.ts new file mode 100644 index 0000000..f714f88 --- /dev/null +++ b/apps/web/src/lib/feed-ranking.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, test } from "bun:test"; +import { + ORG_DIVERSITY_CAP, + ORG_DIVERSITY_WINDOW, + type ScoringContext, + type ScoringInput, + WEIGHTS, + compareRecentlyPosted, + compareScored, + compareSoonest, + finalizeFeedOrder, + postedAt, + scoreEvent, + sourceQuality, +} from "./feed-ranking"; + +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; +const NOW = Date.parse("2026-09-28T16:00:00.000Z"); + +const ctx = (overrides: Partial = {}): ScoringContext => ({ + userId: "viewer", + now: NOW, + interestTags: new Set(), + myOrgIds: new Set(), + interactedOrgIds: new Set(), + ...overrides, +}); + +const event = (overrides: Partial & { id: string }): ScoringInput => ({ + orgId: null, + startsAt: NOW + 2 * DAY, + postedAt: NOW - 10 * DAY, + tags: [], + friendsAttendingCount: 0, + viewCount: 0, + source: "myprincetonu", + announcementCount: 0, + ...overrides, +}); + +/** Score → sort → finalize, exactly as `loadRankedFeed` does for "For you". */ +function rankForYou(inputs: ScoringInput[], c: ScoringContext = ctx()) { + const scored = inputs.map((e) => ({ ...e, score: scoreEvent(e, c) })); + scored.sort(compareScored); + return finalizeFeedOrder(scored, { now: c.now }); +} + +describe("sourceQuality", () => { + test("listserv announcements boost, growing gently with repeats", () => { + expect(sourceQuality("listserv", 1)).toBeCloseTo(0.5); + expect(sourceQuality("listserv", 2)).toBeCloseTo(Math.log2(3) / 2); + expect(sourceQuality("listserv", 3)).toBe(1); + expect(sourceQuality("listserv", 12)).toBe(1); + }); + + test("an official event that was also emailed gets the same boost", () => { + expect(sourceQuality("myprincetonu", 1)).toBe(sourceQuality("listserv", 1)); + }); + + test("unannounced MyPrincetonU listings sit below neutral; Forum events slightly above", () => { + expect(sourceQuality("myprincetonu", 0)).toBeLessThan(0); + expect(sourceQuality("manual", 0)).toBeGreaterThan(0); + expect(sourceQuality("manual", 0)).toBeLessThan(sourceQuality("listserv", 1)); + expect(sourceQuality("legacy-import", 0)).toBe(0); + }); +}); + +describe("For you: listserv-announced events outrank unannounced MyPrincetonU listings", () => { + test("otherwise-identical events: announced wins by more than the random nudge can undo", () => { + const c = ctx(); + const announced = scoreEvent(event({ id: "a", source: "listserv", announcementCount: 1 }), c); + const official = scoreEvent(event({ id: "b", source: "myprincetonu" }), c); + expect(announced - official).toBeGreaterThan(WEIGHTS.random + 1); + }); + + test("an emailed official event beats an unannounced one", () => { + const c = ctx(); + const emailed = scoreEvent(event({ id: "a", announcementCount: 2 }), c); + const silent = scoreEvent(event({ id: "b" }), c); + expect(emailed).toBeGreaterThan(silent + WEIGHTS.random); + }); + + test("a feed dominated by MyPrincetonU listings still leads with the announced ones", () => { + // 30 unannounced official listings and 5 announced events, all in the next + // four days, no personal signals — the shape of the real feed. + const inputs: ScoringInput[] = []; + for (let i = 0; i < 30; i++) { + inputs.push(event({ id: `mpu-${i}`, startsAt: NOW + (i % 4) * DAY + 6 * HOUR })); + } + for (let i = 0; i < 5; i++) { + inputs.push( + event({ + id: `list-${i}`, + source: "listserv", + announcementCount: 1 + (i % 2), + startsAt: NOW + (i % 4) * DAY + 8 * HOUR, + }), + ); + } + const order = rankForYou(inputs).map((e) => e.id); + const firstFive = order.slice(0, 5); + // The soon-event quota may pull an imminent listing forward, but the + // announced events all land in the first handful of positions. + const announcedPositions = order + .map((id, i) => (id.startsWith("list-") ? i : -1)) + .filter((i) => i >= 0); + expect(Math.max(...announcedPositions)).toBeLessThan(8); + expect(firstFive.filter((id) => id.startsWith("list-")).length).toBeGreaterThanOrEqual(4); + }); + + test("among announced events, sooner still ranks higher", () => { + const c = ctx(); + const tomorrow = scoreEvent( + event({ id: "a", source: "listserv", announcementCount: 1, startsAt: NOW + DAY }), + c, + ); + const inThreeWeeks = scoreEvent( + event({ id: "b", source: "listserv", announcementCount: 1, startsAt: NOW + 21 * DAY }), + c, + ); + expect(tomorrow).toBeGreaterThan(inThreeWeeks + WEIGHTS.random); + }); +}); + +describe("For you: followed orgs rise", () => { + test("following an org adds the full org weight", () => { + const e = event({ id: "a", orgId: "org-1" }); + const base = scoreEvent(e, ctx()); + const followed = scoreEvent(e, ctx({ myOrgIds: new Set(["org-1"]) })); + expect(followed - base).toBeCloseTo(WEIGHTS.org); + }); + + test("a followed org's event two weeks out outranks an unfollowed one in two days", () => { + const c = ctx({ myOrgIds: new Set(["club"]) }); + const followedFar = scoreEvent(event({ id: "a", orgId: "club", startsAt: NOW + 14 * DAY }), c); + const otherSoon = scoreEvent(event({ id: "b", orgId: "other", startsAt: NOW + 2 * DAY }), c); + expect(followedFar).toBeGreaterThan(otherSoon + WEIGHTS.random); + }); + + test("followed events reach the first page, without flooding it", () => { + const inputs: ScoringInput[] = []; + for (let i = 0; i < 60; i++) { + inputs.push( + event({ + id: `other-${i}`, + orgId: `org-${i}`, + source: "listserv", + announcementCount: 1, + startsAt: NOW + (i % 10) * DAY + 5 * HOUR, + }), + ); + } + for (let i = 0; i < 6; i++) { + inputs.push(event({ id: `club-${i}`, orgId: "club", startsAt: NOW + (10 + i * 2) * DAY })); + } + + const unfollowed = rankForYou(inputs).map((e) => e.id); + const followed = rankForYou(inputs, ctx({ myOrgIds: new Set(["club"]) })).map((e) => e.id); + + const firstPage = (order: string[]) => + order.slice(0, ORG_DIVERSITY_WINDOW).filter((id) => id.startsWith("club-")).length; + + expect(firstPage(unfollowed)).toBe(0); + // Hoisted onto page 1, but the org cap still limits it to 3 per 20. + expect(firstPage(followed)).toBe(ORG_DIVERSITY_CAP); + // Every followed event rises. + for (let i = 0; i < 6; i++) { + expect(followed.indexOf(`club-${i}`)).toBeLessThan(unfollowed.indexOf(`club-${i}`)); + } + }); +}); + +describe("Soonest", () => { + test("start time ascending, ties by id", () => { + const items = [ + { id: "c", startsAt: NOW + 3 * DAY }, + { id: "b", startsAt: NOW + DAY }, + { id: "a", startsAt: NOW + DAY }, + ]; + expect([...items].sort(compareSoonest).map((e) => e.id)).toEqual(["a", "b", "c"]); + }); +}); + +describe("Recently posted", () => { + test("posted time is the first announcement, else when it entered The Forum", () => { + const created = new Date(NOW - 5 * DAY); + const announced = new Date(NOW - 2 * HOUR); + expect(postedAt({ announcedAt: announced, createdAt: created })).toBe(announced.getTime()); + expect(postedAt({ announcedAt: null, createdAt: created })).toBe(created.getTime()); + }); + + test("ordered by announcement time, newest first", () => { + const rows = [ + { id: "old-email", announcedAt: new Date(NOW - 3 * DAY), createdAt: new Date(NOW - HOUR) }, + { id: "new-email", announcedAt: new Date(NOW - 1 * HOUR), createdAt: new Date(NOW) }, + { id: "mpu-listing", announcedAt: null, createdAt: new Date(NOW - 2 * DAY) }, + { id: "forum-post", announcedAt: null, createdAt: new Date(NOW - 5 * HOUR) }, + ].map((r, i) => ({ id: r.id, postedAt: postedAt(r), startsAt: NOW + i * DAY })); + + expect([...rows].sort(compareRecentlyPosted).map((e) => e.id)).toEqual([ + "new-email", + "forum-post", + "mpu-listing", + "old-email", + ]); + }); + + test("same posted time falls back to soonest first", () => { + const items = [ + { id: "later", postedAt: NOW, startsAt: NOW + 2 * DAY }, + { id: "sooner", postedAt: NOW, startsAt: NOW + DAY }, + ]; + expect([...items].sort(compareRecentlyPosted).map((e) => e.id)).toEqual(["sooner", "later"]); + }); +}); diff --git a/apps/web/src/lib/feed-ranking.ts b/apps/web/src/lib/feed-ranking.ts index 29bf781..62ef1da 100644 --- a/apps/web/src/lib/feed-ranking.ts +++ b/apps/web/src/lib/feed-ranking.ts @@ -27,13 +27,29 @@ export const CANDIDATE_HORIZON_DAYS = 45; export const CANDIDATE_POOL_CAP = 1000; export const PERSONAL_CANDIDATE_CAP = 500; +// ── Sorts ───────────────────────────────────────────────── + +/** + * Home/Explore sort options. Every sort ranks the same candidate set (same + * filters, same horizon, blocked orgs excluded); only the order differs. + * - `foryou`: the weighted score below, plus the org-diversity cap and the + * soon-event quota. + * - `soonest`: start time ascending. + * - `recent`: when it was posted — first listserv announcement, else when it + * entered The Forum — newest first. + */ +export const FEED_SORTS = ["foryou", "soonest", "recent"] as const; +export type FeedSort = (typeof FEED_SORTS)[number]; +export const DEFAULT_FEED_SORT: FeedSort = "foryou"; + // ── Scoring ─────────────────────────────────────────────── export const WEIGHTS = { interest: 3.0, time: 2.0, friends: 4.0, - org: 1.0, + org: 4.0, + source: 2.5, recency: 1.0, popularity: 0.5, random: 0.5, @@ -42,8 +58,27 @@ export const WEIGHTS = { /** Time proximity halves every this many days. */ export const TIME_HALF_LIFE_DAYS = 4; -/** Org affinity when you've RSVP'd to the org before but don't follow/belong to it. */ -export const ORG_PAST_INTERACTION_AFFINITY = 0.5; +/** + * Org affinity when you've RSVP'd to the org before but don't follow/belong to + * it. With WEIGHTS.org = 4 that is worth 0.6 — about what it was before + * following was strengthened — while a follow is worth the full 4.0. + */ +export const ORG_PAST_INTERACTION_AFFINITY = 0.15; + +/** + * Source quality, multiplied by WEIGHTS.source. Events announced on a campus + * listserv earn up to 1.0, growing gently with repeat announcements (see + * `sourceQuality`). Official MyPrincetonU listings nobody emailed about are + * numerous and uneven, so they sit slightly below neutral and fill in under + * announced events. Events created in The Forum are posted by a student on + * purpose: slightly positive. + */ +export const SOURCE_QUALITY = { + /** An announcement count that earns the full boost (log2(1 + 3) / 2 = 1). */ + announcementsForFull: 3, + unannouncedOfficial: -0.3, + manual: 0.2, +} as const; /** View count treated as "maximally popular" (log-scaled, caps at 1.0). */ export const POPULARITY_VIEW_CAP = 50; @@ -87,10 +122,32 @@ export interface ScoringInput { id: string; orgId: string | null; startsAt: number; - createdAt: number; + /** When the event was posted: first listserv announcement, else when it entered The Forum. */ + postedAt: number; tags: readonly string[]; friendsAttendingCount: number; viewCount: number; + /** 'manual' | 'myprincetonu' | 'listserv' (or a legacy value, treated as neutral). */ + source: string; + /** Campus listserv emails that announced the event (InboxEngine). */ + announcementCount: number; +} + +/** + * Source-quality signal in [-0.3, 1]: + * - announced on a listserv (any source): min(1, log2(1 + emails) / 2) — + * 0.5 for one email, ~0.79 for two, 1.0 from three on; + * - official MyPrincetonU listing never announced: SOURCE_QUALITY.unannouncedOfficial; + * - created in The Forum: SOURCE_QUALITY.manual; + * - anything else: 0. + */ +export function sourceQuality(source: string, announcementCount: number): number { + if (announcementCount > 0) { + return Math.min(1, Math.log2(1 + announcementCount) / 2); + } + if (source === "myprincetonu") return SOURCE_QUALITY.unannouncedOfficial; + if (source === "manual") return SOURCE_QUALITY.manual; + return 0; } export function scoreEvent(event: ScoringInput, ctx: ScoringContext): number { @@ -114,8 +171,10 @@ export function scoreEvent(event: ScoringInput, ctx: ScoringContext): number { ? ORG_PAST_INTERACTION_AFFINITY : 0; - const hoursSinceCreated = (ctx.now - event.createdAt) / HOUR_MS; - const recencyBoost = hoursSinceCreated <= 24 ? 1.0 : hoursSinceCreated <= 72 ? 0.5 : 0.0; + const hoursSincePosted = (ctx.now - event.postedAt) / HOUR_MS; + const recencyBoost = hoursSincePosted <= 24 ? 1.0 : hoursSincePosted <= 72 ? 0.5 : 0.0; + + const source = sourceQuality(event.source, event.announcementCount); const popularityScore = Math.min( 1.0, @@ -131,6 +190,7 @@ export function scoreEvent(event: ScoringInput, ctx: ScoringContext): number { WEIGHTS.time * timeProximity + WEIGHTS.friends * friendRsvpScore + WEIGHTS.org * orgAffinity + + WEIGHTS.source * source + WEIGHTS.recency * recencyBoost + WEIGHTS.popularity * popularityScore + WEIGHTS.random * randomNudge @@ -147,6 +207,33 @@ export function compareScored( return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; } +/** + * When an event was posted, for "Recently posted" and the recency boost: its + * first listserv announcement if it had one, else when it entered The Forum + * (created there, or first synced from MyPrincetonU). + */ +export function postedAt(event: { announcedAt: Date | null; createdAt: Date }): number { + return (event.announcedAt ?? event.createdAt).getTime(); +} + +/** "Soonest": start time ascending, then id. */ +export function compareSoonest( + a: { id: string; startsAt: number }, + b: { id: string; startsAt: number }, +): number { + if (a.startsAt !== b.startsAt) return a.startsAt - b.startsAt; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; +} + +/** "Recently posted": posted time descending, then soonest first, then id. */ +export function compareRecentlyPosted( + a: { id: string; postedAt: number; startsAt: number }, + b: { id: string; postedAt: number; startsAt: number }, +): number { + if (a.postedAt !== b.postedAt) return b.postedAt - a.postedAt; + return compareSoonest(a, b); +} + export interface OrderingOptions { now: number; orgCap?: number; diff --git a/apps/web/src/lib/feed.ts b/apps/web/src/lib/feed.ts index 5178983..ef70b4f 100644 --- a/apps/web/src/lib/feed.ts +++ b/apps/web/src/lib/feed.ts @@ -3,6 +3,7 @@ import { and, campusLocations, db, + desc, eq, eventTags, gt, @@ -11,6 +12,7 @@ import { inArray, interactions, lt, + lte, or, orgFollowers, orgMembers, @@ -23,14 +25,18 @@ import { } from "@the-forum/database"; import type { FeedEvent } from "~/actions/events"; import { formatEventDateTime } from "~/lib/date-format"; -import { eventDiscoverableBy } from "~/lib/event-visibility"; +import { eventDiscoverableBy, hiddenOrgIdsQuery, notFromHiddenOrg } from "~/lib/event-visibility"; import { CANDIDATE_HORIZON_DAYS, CANDIDATE_POOL_CAP, + type FeedSort, PERSONAL_CANDIDATE_CAP, type ScoringContext, + compareRecentlyPosted, compareScored, + compareSoonest, finalizeFeedOrder, + postedAt, scoreEvent, } from "~/lib/feed-ranking"; import { loadFriendIds } from "~/lib/social-graph"; @@ -45,6 +51,8 @@ export interface FeedQuery { orgCategory?: OrgCategory; locationId?: string; dateRange?: "today" | "week" | "month"; + /** Defaults to "foryou". */ + sort?: FeedSort; limit: number; offset: number; /** @@ -57,21 +65,29 @@ export interface FeedQuery { export interface FeedPage { events: FeedEvent[]; - /** Size of the ranked candidate list — exactly what offset/limit can reach. */ + /** Events in this ranking the viewer can still see (orgs hidden since `asOf` drop out). */ total: number; + /** Pass back as `offset` for the next page. */ + nextOffset: number; + /** Visible events after `nextOffset` — 0 means this was the last page. */ + remaining: number; /** ISO timestamp the ranking was computed for; pass it back as `asOf` for the next page. */ asOf: string; + sort: FeedSort; } /** A stale `asOf` is ignored, so a tab left open overnight gets a fresh feed. */ const ASOF_MAX_AGE_MS = 30 * 60 * 1000; -function resolveNow(asOf: Date | undefined): number { +/** The ranking instant, and whether it was pinned to a client-supplied `asOf`. */ +function resolveNow(asOf: Date | undefined): { now: number; pinned: boolean } { const realNow = Date.now(); - if (!asOf) return realNow; + if (!asOf) return { now: realNow, pinned: false }; const t = asOf.getTime(); - if (Number.isNaN(t) || t > realNow + 60_000 || realNow - t > ASOF_MAX_AGE_MS) return realNow; - return t; + if (Number.isNaN(t) || t > realNow + 60_000 || realNow - t > ASOF_MAX_AGE_MS) { + return { now: realNow, pinned: false }; + } + return { now: t, pinned: true }; } function dateRangeEnd(now: number, dateRange: FeedQuery["dateRange"]): Date { @@ -88,22 +104,36 @@ function dateRangeEnd(now: number, dateRange: FeedQuery["dateRange"]): Date { return end; } +/** SQL twin of `postedAt()` in ~/lib/feed-ranking — keep the two in step. */ +const postedAtSql = sql`coalesce(${events.announcedAt}, ${events.createdAt})`; + /** - * Rank the Explore feed for `userId` and return one page of it. + * One page of the Explore feed for `userId`, in the requested sort. * - * 1. Candidate generation (docs/ranking.md, "Candidate pool"). - * 2. Batched enrichment of every candidate — one `inArray` query per signal. - * 3. Score, sort, then finalize ONE order over the whole list (org cap + - * soon-event quota) before slicing out the requested page. - * 4. Attendee rosters are loaded for the returned page only. + * 1. Candidate generation (docs/ranking.md, "Candidate pool") — the same set + * for every sort; hidden orgs are excluded here. + * 2. Order the whole candidate list. "For you" first enriches every candidate + * (one `inArray` query per signal), scores it, then applies the org cap and + * soon-event quota; "Soonest" and "Recently posted" are plain sorts. + * 3. Slice out the requested page, drop orgs hidden since `asOf`, and load the + * page's display data (tags, attendees, the viewer's RSVP/save state). */ export async function loadRankedFeed(userId: string, query: FeedQuery): Promise { - const now = resolveNow(query.asOf); + const sort = query.sort ?? "foryou"; + const { now, pinned } = resolveNow(query.asOf); const nowDate = new Date(now); const asOf = nowDate.toISOString(); + /* + * On later pages (pinned to page 1's `asOf`), read viewer state as it was at + * `asOf`: follows and hides made since then don't reorder a ranking the + * client is already paging through, and events that entered The Forum since + * then wait for the next fresh load. Otherwise a follow or a new event could + * shift every later position by one, duplicating or skipping an event. + */ + const snapshot = pinned ? nowDate : undefined; // ── Viewer context ────────────────────────────────────── - const [interestRows, friendIds, followedOrgRows, memberOrgRows, interactedOrgRows] = + const [interestRows, friendIds, followedOrgRows, memberOrgRows, interactedOrgRows, hiddenNow] = await Promise.all([ db .select({ tag: userInterests.tag }) @@ -113,7 +143,11 @@ export async function loadRankedFeed(userId: string, query: FeedQuery): Promise< db .select({ orgId: orgFollowers.orgId }) .from(orgFollowers) - .where(eq(orgFollowers.userId, userId)), + .where( + snapshot + ? and(eq(orgFollowers.userId, userId), lte(orgFollowers.createdAt, snapshot)) + : eq(orgFollowers.userId, userId), + ), db.select({ orgId: orgMembers.orgId }).from(orgMembers).where(eq(orgMembers.userId, userId)), // Orgs the user has RSVP'd to before — a weaker org-affinity signal. db @@ -121,6 +155,8 @@ export async function loadRankedFeed(userId: string, query: FeedQuery): Promise< .from(rsvps) .innerJoin(events, eq(rsvps.eventId, events.id)) .where(eq(rsvps.userId, userId)), + // Every org hidden right now, including since `asOf` — filtered from the page. + hiddenOrgIdsQuery(userId), ]); const interestTags = interestRows.map((r) => r.tag); @@ -131,6 +167,9 @@ export async function loadRankedFeed(userId: string, query: FeedQuery): Promise< const interactedOrgIds = new Set( interactedOrgRows.map((r) => r.orgId).filter((id): id is string => id !== null), ); + const hiddenOrgIds = new Set(hiddenNow.map((r) => r.orgId)); + const isVisible = (item: { orgId: string | null }) => + !item.orgId || !hiddenOrgIds.has(item.orgId); // ── Candidate generation ──────────────────────────────── const conditions = [ @@ -138,7 +177,10 @@ export async function loadRankedFeed(userId: string, query: FeedQuery): Promise< lt(events.datetime, dateRangeEnd(now, query.dateRange)), // Published AND visible to this viewer — see ~/lib/event-visibility.ts. eventDiscoverableBy(userId), + // Not from an org the viewer hid (as of `asOf` on later pages). + notFromHiddenOrg(userId, snapshot), ]; + if (snapshot) conditions.push(lte(events.createdAt, snapshot)); if (query.search) { const pattern = containsPattern(query.search); @@ -183,45 +225,58 @@ export async function loadRankedFeed(userId: string, query: FeedQuery): Promise< orgLogoUrl: organizations.logoUrl, locationDetail: events.locationDetail, createdAt: events.createdAt, + announcedAt: events.announcedAt, + announcementCount: events.announcementCount, + source: events.source, }; - // Stage 1: everything within the horizon, soonest first, up to the cap. + // Stage 1: everything within the horizon, up to the cap — soonest first, or + // for "Recently posted" newest-posted first, so the cap (if it ever binds) + // keeps the events that sort would show first. const timeOrdered = await db .select(candidateColumns) .from(events) .leftJoin(campusLocations, eq(events.locationId, campusLocations.id)) .leftJoin(organizations, eq(events.orgId, organizations.id)) .where(and(...conditions)) - .orderBy(events.datetime, events.id) + .orderBy( + ...(sort === "recent" + ? [desc(postedAtSql), events.datetime, events.id] + : [events.datetime, events.id]), + ) .limit(CANDIDATE_POOL_CAP); let candidates = timeOrdered; - // Stage 2 (only if stage 1 saturated): events past the stage-1 cutoff that - // carry a personal signal, so a dense calendar can't wall them off. + // Stage 2 ("For you" only, and only if stage 1 saturated): events past the + // stage-1 cutoff that carry a personal signal, so a dense calendar can't wall + // them off. const lastTimeOrdered = timeOrdered[timeOrdered.length - 1]; if (timeOrdered.length === CANDIDATE_POOL_CAP && lastTimeOrdered) { - const personalSignals = [ - myOrgIds.size > 0 ? inArray(events.orgId, [...myOrgIds]) : undefined, - friendIds.length > 0 - ? inArray( - events.id, - db - .select({ eventId: rsvps.eventId }) - .from(rsvps) - .where(inArray(rsvps.userId, friendIds)), - ) - : undefined, - interestTags.length > 0 - ? inArray( - events.id, - db - .select({ eventId: eventTags.eventId }) - .from(eventTags) - .where(inArray(eventTags.tag, interestTags)), - ) - : undefined, - ].filter((c) => c !== undefined); + const personalSignals = + sort !== "foryou" + ? [] + : [ + myOrgIds.size > 0 ? inArray(events.orgId, [...myOrgIds]) : undefined, + friendIds.length > 0 + ? inArray( + events.id, + db + .select({ eventId: rsvps.eventId }) + .from(rsvps) + .where(inArray(rsvps.userId, friendIds)), + ) + : undefined, + interestTags.length > 0 + ? inArray( + events.id, + db + .select({ eventId: eventTags.eventId }) + .from(eventTags) + .where(inArray(eventTags.tag, interestTags)), + ) + : undefined, + ].filter((c) => c !== undefined); if (personalSignals.length > 0) { const personal = await db @@ -244,117 +299,145 @@ export async function loadRankedFeed(userId: string, query: FeedQuery): Promise< } console.warn( - `loadRankedFeed: time-ordered candidate pool hit CANDIDATE_POOL_CAP (${CANDIDATE_POOL_CAP}); ` + - `ranked ${candidates.length} candidates after personalised expansion.`, + `loadRankedFeed: candidate pool hit CANDIDATE_POOL_CAP (${CANDIDATE_POOL_CAP}) for sort=${sort}; ` + + `ordering ${candidates.length} candidates.`, ); } - if (candidates.length === 0) return { events: [], total: 0, asOf }; + if (candidates.length === 0) { + return { events: [], total: 0, nextOffset: 0, remaining: 0, asOf, sort }; + } - // ── Batched enrichment (one query per signal, whole candidate set) ── - const candidateIds = candidates.map((e) => e.id); - const [tagRows, rsvpCountRows, viewCountRows, friendRsvpRows, myRsvpRows, mySaveRows] = - await Promise.all([ - db - .select({ eventId: eventTags.eventId, tag: eventTags.tag }) - .from(eventTags) - .where(inArray(eventTags.eventId, candidateIds)), - db - .select({ eventId: rsvps.eventId, count: sql`count(*)::int` }) - .from(rsvps) - .where(inArray(rsvps.eventId, candidateIds)) - .groupBy(rsvps.eventId), - // Distinct viewers, not raw view rows, so one user re-opening (or - // scripting) an event can't inflate its popularity. - db - .select({ - eventId: interactions.itemId, - count: sql`count(distinct ${interactions.userId})::int`, - }) - .from(interactions) - .where( - and( - inArray(interactions.itemId, candidateIds), - eq(interactions.itemType, "event"), - eq(interactions.interactionType, "view"), - ), - ) - .groupBy(interactions.itemId), - friendIds.length === 0 - ? Promise.resolve([]) - : db - .select({ - eventId: rsvps.eventId, - id: users.id, - displayName: users.displayName, - avatarUrl: users.avatarUrl, - }) - .from(rsvps) - .innerJoin(users, eq(rsvps.userId, users.id)) - .where(and(inArray(rsvps.eventId, candidateIds), inArray(rsvps.userId, friendIds))), - db - .select({ eventId: rsvps.eventId }) - .from(rsvps) - .where(and(eq(rsvps.userId, userId), inArray(rsvps.eventId, candidateIds))), - db - .select({ eventId: savedEvents.eventId }) - .from(savedEvents) - .where(and(eq(savedEvents.userId, userId), inArray(savedEvents.eventId, candidateIds))), - ]); + type Candidate = (typeof candidates)[number]; + const items = candidates.map((event: Candidate) => ({ + event, + id: event.id, + orgId: event.orgId, + startsAt: event.datetime.getTime(), + postedAt: postedAt(event), + })); - const tagsByEvent = groupBy( - tagRows, - (r) => r.eventId, - (r) => r.tag, - ); - const friendsByEvent = groupBy( - friendRsvpRows, - (r) => r.eventId, - (r) => ({ id: r.id, displayName: r.displayName, avatarUrl: r.avatarUrl }), - ); - const rsvpCountByEvent = new Map(rsvpCountRows.map((r) => [r.eventId, r.count])); - const viewCountByEvent = new Map(viewCountRows.map((r) => [r.eventId, r.count])); - const myRsvps = new Set(myRsvpRows.map((r) => r.eventId)); - const mySaves = new Set(mySaveRows.map((r) => r.eventId)); - - // ── Score, sort, finalize ─────────────────────────────── - const ctx: ScoringContext = { - userId, - now, - interestTags: new Set(interestTags), - myOrgIds, - interactedOrgIds, - }; + // ── Order the whole list ──────────────────────────────── + let ordered: typeof items; + /** Scoring signals, loaded for every candidate ("For you") or just the page. */ + let signals: FeedSignals; - const scored = candidates.map((event) => { - const tags = tagsByEvent.get(event.id) ?? []; - const friendsAttending = friendsByEvent.get(event.id) ?? []; - const startsAt = event.datetime.getTime(); - const score = scoreEvent( - { - id: event.id, - orgId: event.orgId, - startsAt, - createdAt: event.createdAt.getTime(), - tags, - friendsAttendingCount: friendsAttending.length, - viewCount: viewCountByEvent.get(event.id) ?? 0, - }, - ctx, + if (sort === "foryou") { + signals = await loadFeedSignals( + items.map((i) => i.id), + friendIds, ); - return { event, tags, friendsAttending, startsAt, score, id: event.id, orgId: event.orgId }; - }); - scored.sort(compareScored); + const ctx: ScoringContext = { + userId, + now, + interestTags: new Set(interestTags), + myOrgIds, + interactedOrgIds, + }; + const scored = items.map((item) => ({ + ...item, + score: scoreEvent( + { + id: item.id, + orgId: item.orgId, + startsAt: item.startsAt, + postedAt: item.postedAt, + tags: signals.tags.get(item.id) ?? [], + friendsAttendingCount: signals.friends.get(item.id)?.length ?? 0, + viewCount: signals.views.get(item.id) ?? 0, + source: item.event.source, + announcementCount: item.event.announcementCount, + }, + ctx, + ), + })); + scored.sort(compareScored); + ordered = finalizeFeedOrder(scored, { now }); + } else { + ordered = [...items].sort(sort === "soonest" ? compareSoonest : compareRecentlyPosted); + signals = { tags: new Map(), friends: new Map(), views: new Map() }; + } - const ordered = finalizeFeedOrder(scored, { now }); - const page = ordered.slice(query.offset, query.offset + query.limit); + // ── Paginate ──────────────────────────────────────────── + // Offsets index the full ordered list; orgs hidden since `asOf` are dropped + // from the output (and the counts) without shifting anyone's position. + const nextOffset = Math.min(ordered.length, query.offset + query.limit); + const page = ordered.slice(query.offset, nextOffset).filter(isVisible); + const total = hiddenOrgIds.size === 0 ? ordered.length : ordered.filter(isVisible).length; + const remaining = ordered.slice(nextOffset).filter(isVisible).length; - // ── Attendee rosters: final page only ─────────────────── + // ── Page display data ─────────────────────────────────── const pageIds = page.map((p) => p.id); - const attendeeRows = - pageIds.length === 0 - ? [] - : await db + if (sort !== "foryou") signals = await loadFeedSignals(pageIds, friendIds); + const details = await loadPageDetails(userId, pageIds); + + return { + events: page.map(({ event }) => { + const attendees = details.attendees.get(event.id) ?? []; + return { + id: event.id, + title: event.title, + description: event.description, + orgId: event.orgId, + orgName: event.orgName, + orgLogoUrl: event.orgLogoUrl ?? null, + isFollowingOrg: event.orgId ? details.followedOrgIds.has(event.orgId) : false, + datetime: formatEventDateTime(event.datetime), + rawDatetime: event.datetime.toISOString(), + location: event.locationName ?? "TBD", + locationDetail: event.locationDetail ?? null, + tags: signals.tags.get(event.id) ?? [], + flyerUrl: event.flyerUrl, + rsvpCount: attendees.length, + friendsAttending: signals.friends.get(event.id) ?? [], + attendees, + isRsvped: details.rsvped.has(event.id), + isSaved: details.saved.has(event.id), + }; + }), + total, + nextOffset, + remaining, + asOf, + sort, + }; +} + +type Person = { id: string; displayName: string; avatarUrl: string | null }; + +interface FeedSignals { + tags: Map; + friends: Map; + views: Map; +} + +/** Tags, friends attending and distinct-viewer counts — one query each, for any set of events. */ +async function loadFeedSignals(eventIds: string[], friendIds: string[]): Promise { + if (eventIds.length === 0) return { tags: new Map(), friends: new Map(), views: new Map() }; + const [tagRows, viewCountRows, friendRsvpRows] = await Promise.all([ + db + .select({ eventId: eventTags.eventId, tag: eventTags.tag }) + .from(eventTags) + .where(inArray(eventTags.eventId, eventIds)), + // Distinct viewers, not raw view rows, so one user re-opening (or + // scripting) an event can't inflate its popularity. + db + .select({ + eventId: interactions.itemId, + count: sql`count(distinct ${interactions.userId})::int`, + }) + .from(interactions) + .where( + and( + inArray(interactions.itemId, eventIds), + eq(interactions.itemType, "event"), + eq(interactions.interactionType, "view"), + ), + ) + .groupBy(interactions.itemId), + friendIds.length === 0 + ? Promise.resolve([]) + : db .select({ eventId: rsvps.eventId, id: users.id, @@ -363,36 +446,66 @@ export async function loadRankedFeed(userId: string, query: FeedQuery): Promise< }) .from(rsvps) .innerJoin(users, eq(rsvps.userId, users.id)) - .where(inArray(rsvps.eventId, pageIds)) - .orderBy(rsvps.createdAt); - const attendeesByEvent = groupBy( - attendeeRows, - (r) => r.eventId, - (r) => ({ id: r.id, displayName: r.displayName, avatarUrl: r.avatarUrl }), - ); + .where(and(inArray(rsvps.eventId, eventIds), inArray(rsvps.userId, friendIds))), + ]); + return { + tags: groupBy( + tagRows, + (r) => r.eventId, + (r) => r.tag, + ), + friends: groupBy( + friendRsvpRows, + (r) => r.eventId, + (r) => ({ id: r.id, displayName: r.displayName, avatarUrl: r.avatarUrl }), + ), + views: new Map(viewCountRows.map((r) => [r.eventId, r.count])), + }; +} +interface PageDetails { + attendees: Map; + rsvped: Set; + saved: Set; + /** Orgs the viewer follows right now — the card menu's Follow/Unfollow state. */ + followedOrgIds: Set; +} + +/** Attendee rosters, the viewer's RSVP/save state and current follows — for the returned page only. */ +async function loadPageDetails(userId: string, pageIds: string[]): Promise { + if (pageIds.length === 0) { + return { attendees: new Map(), rsvped: new Set(), saved: new Set(), followedOrgIds: new Set() }; + } + const [attendeeRows, saveRows, followRows] = await Promise.all([ + db + .select({ + eventId: rsvps.eventId, + id: users.id, + displayName: users.displayName, + avatarUrl: users.avatarUrl, + }) + .from(rsvps) + .innerJoin(users, eq(rsvps.userId, users.id)) + .where(inArray(rsvps.eventId, pageIds)) + .orderBy(rsvps.createdAt), + db + .select({ eventId: savedEvents.eventId }) + .from(savedEvents) + .where(and(eq(savedEvents.userId, userId), inArray(savedEvents.eventId, pageIds))), + db + .select({ orgId: orgFollowers.orgId }) + .from(orgFollowers) + .where(eq(orgFollowers.userId, userId)), + ]); return { - events: page.map(({ event, tags, friendsAttending }) => ({ - id: event.id, - title: event.title, - description: event.description, - orgId: event.orgId, - orgName: event.orgName, - orgLogoUrl: event.orgLogoUrl ?? null, - datetime: formatEventDateTime(event.datetime), - rawDatetime: event.datetime.toISOString(), - location: event.locationName ?? "TBD", - locationDetail: event.locationDetail ?? null, - tags, - flyerUrl: event.flyerUrl, - rsvpCount: rsvpCountByEvent.get(event.id) ?? 0, - friendsAttending, - attendees: attendeesByEvent.get(event.id) ?? [], - isRsvped: myRsvps.has(event.id), - isSaved: mySaves.has(event.id), - })), - total: ordered.length, - asOf, + attendees: groupBy( + attendeeRows, + (r) => r.eventId, + (r) => ({ id: r.id, displayName: r.displayName, avatarUrl: r.avatarUrl }), + ), + rsvped: new Set(attendeeRows.filter((r) => r.id === userId).map((r) => r.eventId)), + saved: new Set(saveRows.map((r) => r.eventId)), + followedOrgIds: new Set(followRows.map((r) => r.orgId)), }; } diff --git a/apps/web/src/lib/greeting-name.test.ts b/apps/web/src/lib/greeting-name.test.ts new file mode 100644 index 0000000..2fde5ac --- /dev/null +++ b/apps/web/src/lib/greeting-name.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test"; +import { greetingName } from "./greeting-name"; + +describe("greetingName", () => { + test("first word of a real display name", () => { + expect(greetingName("Albert Einstein", "ae1234")).toBe("Albert"); + expect(greetingName(" Albert ", "ae1234")).toBe("Albert"); + }); + + test("no name when the display name is the NetID (any case) or empty", () => { + expect(greetingName("ia1234", "ia1234")).toBeNull(); + expect(greetingName("IA1234", "ia1234")).toBeNull(); + expect(greetingName("", "ia1234")).toBeNull(); + expect(greetingName(" ", "ia1234")).toBeNull(); + expect(greetingName(null, "ia1234")).toBeNull(); + expect(greetingName(undefined, undefined)).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/greeting-name.ts b/apps/web/src/lib/greeting-name.ts new file mode 100644 index 0000000..5519c7f --- /dev/null +++ b/apps/web/src/lib/greeting-name.ts @@ -0,0 +1,16 @@ +/** + * The name a greeting addresses someone by: the first word of their display + * name — or nothing, when there is no real name yet. New accounts start with + * their NetID as the display name, and "Hello ia1234," reads like a bug, so a + * display name equal to the NetID counts as no name. Callers then render + * "Hello," with no name rather than a placeholder. + */ +export function greetingName( + displayName: string | null | undefined, + netId: string | null | undefined, +): string | null { + const name = displayName?.trim() ?? ""; + if (!name) return null; + if (netId && name.toLowerCase() === netId.trim().toLowerCase()) return null; + return name.split(/\s+/)[0] ?? null; +} diff --git a/apps/web/src/lib/org-preferences.ts b/apps/web/src/lib/org-preferences.ts new file mode 100644 index 0000000..cf35864 --- /dev/null +++ b/apps/web/src/lib/org-preferences.ts @@ -0,0 +1,62 @@ +import { and, db, eq, orgBlocks, orgFollowers } from "@the-forum/database"; + +/** + * Per-viewer org preferences: follow (hoist in the feed) and hide (remove from + * discovery). Server-only; the server actions in `~/actions/orgs.ts` wrap these + * with auth, validation and rate limiting. + * + * The two are mutually exclusive — hiding an org unfollows it and following + * one unhides it — and each write happens in one transaction so the pair can + * never end up both set. Every write is idempotent. + */ + +export interface OrgViewerState { + following: boolean; + hidden: boolean; +} + +export async function loadOrgViewerState(userId: string, orgId: string): Promise { + const [[follow], [block]] = await Promise.all([ + db + .select({ orgId: orgFollowers.orgId }) + .from(orgFollowers) + .where(and(eq(orgFollowers.userId, userId), eq(orgFollowers.orgId, orgId))) + .limit(1), + db + .select({ orgId: orgBlocks.orgId }) + .from(orgBlocks) + .where(and(eq(orgBlocks.userId, userId), eq(orgBlocks.orgId, orgId))) + .limit(1), + ]); + return { following: !!follow, hidden: !!block }; +} + +/** Follow `orgId`, clearing any hide. */ +export async function setFollowing(userId: string, orgId: string): Promise { + await db.transaction(async (tx) => { + await tx.delete(orgBlocks).where(and(eq(orgBlocks.userId, userId), eq(orgBlocks.orgId, orgId))); + await tx.insert(orgFollowers).values({ userId, orgId }).onConflictDoNothing(); + }); +} + +export async function clearFollowing(userId: string, orgId: string): Promise { + await db + .delete(orgFollowers) + .where(and(eq(orgFollowers.userId, userId), eq(orgFollowers.orgId, orgId))); +} + +/** Hide `orgId`, unfollowing it. Returns whether a follow was removed (so Undo can restore it). */ +export async function setHidden(userId: string, orgId: string): Promise<{ unfollowed: boolean }> { + return db.transaction(async (tx) => { + const removed = await tx + .delete(orgFollowers) + .where(and(eq(orgFollowers.userId, userId), eq(orgFollowers.orgId, orgId))) + .returning({ orgId: orgFollowers.orgId }); + await tx.insert(orgBlocks).values({ userId, orgId }).onConflictDoNothing(); + return { unfollowed: removed.length > 0 }; + }); +} + +export async function clearHidden(userId: string, orgId: string): Promise { + await db.delete(orgBlocks).where(and(eq(orgBlocks.userId, userId), eq(orgBlocks.orgId, orgId))); +} diff --git a/apps/web/src/lib/rate-limit.ts b/apps/web/src/lib/rate-limit.ts index 81e69db..7b54780 100644 --- a/apps/web/src/lib/rate-limit.ts +++ b/apps/web/src/lib/rate-limit.ts @@ -26,6 +26,8 @@ export const RATE_LIMITS = { createEvent: { limit: 20, windowMs: HOUR }, createOrg: { limit: 5, windowMs: HOUR }, readEmail: { limit: 120, windowMs: MINUTE }, + /** Follow / unfollow / hide / unhide an org. */ + orgPreference: { limit: 60, windowMs: MINUTE }, } as const satisfies Record; export type RateLimitName = keyof typeof RATE_LIMITS; diff --git a/apps/web/src/lib/validation.ts b/apps/web/src/lib/validation.ts index ba04f4b..eadd62d 100644 --- a/apps/web/src/lib/validation.ts +++ b/apps/web/src/lib/validation.ts @@ -7,6 +7,7 @@ import { orgCategoryEnum, } from "@the-forum/database"; import { z } from "zod"; +import { FEED_SORTS } from "~/lib/feed-ranking"; /** * Parse server-action input, throwing a readable Error on failure. @@ -36,6 +37,9 @@ export const eventStatusSchema = z.enum(eventStatusEnum.enumValues); export const interactionTypeSchema = z.enum(interactionTypeEnum.enumValues); export const itemTypeSchema = z.enum(itemTypeEnum.enumValues); +/** Explore sort ("For you" / "Soonest" / "Recently posted"), e.g. from `?sort=`. */ +export const feedSortSchema = z.enum(FEED_SORTS); + /** De-duplicated list of enum values (at most one of each). */ export function uniqueEnumArray(schema: E) { return z diff --git a/docs/ranking.md b/docs/ranking.md index e4e082d..fd6ab25 100644 --- a/docs/ranking.md +++ b/docs/ranking.md @@ -3,17 +3,36 @@ This describes how `getFeedEvents()` (`apps/web/src/actions/events.ts`, implemented in `apps/web/src/lib/feed.ts`) orders the Explore feed. It's a plain SQL + in-memory weighted score — no ML, no external service. +## Sorts + +Home/Explore has a sort control next to the Cards/List toggle, persisted in the URL +(`?sort=`) and remembered per browser: + +| Sort | `sort=` | Order | +|---|---|---| +| For you (default) | `foryou` | The weighted score below, then the org-diversity cap and soon-event quota | +| Soonest | `soonest` | Start time ascending, then id | +| Recently posted | `recent` | `coalesce(announced_at, created_at)` descending, then soonest, then id | + +Every sort orders the **same candidate set** — same filters (tags, search, …), same horizon, +hidden orgs excluded — so switching sort never changes the count, only the order. "Posted" is +when the event was first announced on a campus listserv (`events.announced_at`, from +InboxEngine), else when it entered The Forum (created here, or first synced from +MyPrincetonU). Soonest and Recently posted are deliberately plain sorts: no diversity cap, no +soon quota — if you ask for chronological, you get chronological. + ## The formula, in plain English -Every upcoming event gets a score built from seven signals, each roughly between 0 and 1, -multiplied by a weight: +For "For you", every upcoming event gets a score built from eight signals, each roughly between +0 and 1 (source quality: −0.3 to 1), multiplied by a weight: | Signal | Weight | What it measures | |---|---|---| | Interest relevance | 3.0 | What fraction of this event's tags match your onboarding interests | | Time proximity | 2.0 | How soon the event is — decays smoothly the further out it is | | Friend RSVPs | 4.0 | How many of your friends are going (caps out at 3+) | -| Org affinity | 1.0 | Whether you follow/belong to the hosting org, or have RSVP'd to it before | +| Org affinity | 4.0 | Whether you follow/belong to the hosting org, or have RSVP'd to it before | +| Source quality | 2.5 | Announced on a campus listserv (boost) vs. an unannounced MyPrincetonU listing (mild penalty) | | Recency | 1.0 | Whether the event was posted in the last 1–3 days | | Popularity | 0.5 | How many views the event has gotten, log-scaled | | Random nudge | 0.5 | A small per-user-per-day nudge so the feed varies over time | @@ -22,15 +41,17 @@ multiplied by a weight: score = 3.0 × interest_relevance + 2.0 × time_proximity + 4.0 × friend_rsvp_score - + 1.0 × org_affinity + + 4.0 × org_affinity + + 2.5 × source_quality + 1.0 × recency_boost + 0.5 × popularity_score + 0.5 × random_nudge ``` -Events are sorted by this score, highest first. Friend RSVPs carry the most weight — "people -you know are going" is the strongest signal on a campus app. Popularity and the random nudge -are deliberately small: they nudge the feed, they don't dominate it. +Events are sorted by this score, highest first. Friend RSVPs and following the host carry the +most weight — "people you know are going" and "you asked to see this org" are the strongest +signals on a campus app. Popularity and the random nudge are deliberately small: they nudge the +feed, they don't dominate it. ### Interest relevance @@ -56,15 +77,45 @@ compelling; it doesn't climb further past that. ### Org affinity — tiered -- **1.0** if you follow or belong to the event's org. -- **0.5** if you don't, but you've RSVP'd to that org's events before +- **1.0** (worth +4.0) if you follow or belong to the event's org. +- **0.15** (worth +0.6) if you don't, but you've RSVP'd to that org's events before (`ORG_PAST_INTERACTION_AFFINITY` in `lib/feed-ranking.ts`) — a weaker signal of interest. - **0** otherwise, or if the event has no org. +Following is an explicit "show me more of this", so it is weighted like friend RSVPs: +4.0 +equals the whole time-proximity range (2.0) and the gap between an announced and an +unannounced listing (2.0) combined, so a followed org's event two or three weeks out still +reaches the first page. The org +diversity cap (below) still limits any one org to 3 of every 20 positions, so following a +prolific org spreads its events across pages rather than flooding page 1. + +### Source quality — listserv announcements + +Official MyPrincetonU listings are numerous (most of the feed) and uneven in quality; an +event someone bothered to announce on a campus listserv is a much better signal. InboxEngine +reports, per event, how many listserv emails announced it (`events.announcement_count`: its +own email for listserv-extracted events, plus reminder emails folded into it; official events +that were also emailed count too). + +``` +announced (count ≥ 1): min(1, log2(1 + count) / 2) → +1.25 / +1.98 / +2.5 (1 / 2 / 3+ emails) +unannounced MyPrincetonU (0): −0.3 → −0.75 +created in The Forum: +0.2 → +0.5 +anything else: 0 +``` + +The boost grows gently with repeats (a reminder email is evidence, not a vote to spam) and +saturates at 3. The gap between one announcement and an unannounced listing (2.0) equals the +whole time-proximity range, so announced events lead and unannounced listings fill in below +them rather than dominating by sheer number — while the soon-event quota still guarantees +tonight's events a place near the top. Forum-created events are posted by a student on +purpose, so they sit slightly above neutral, below an announced event. + ### Recency boost 1.0 if posted in the last 24 hours, 0.5 if posted in the last 3 days, 0 otherwise — surfaces -newly-posted events before other signals catch up. +newly-posted events before other signals catch up. "Posted" is the same instant "Recently +posted" sorts by: first listserv announcement, else when the event entered The Forum. ### Popularity @@ -96,23 +147,28 @@ by soonest event first. |---|---| | `apps/web/src/actions/events.ts` → `getFeedEvents()` | Server action: auth + zod validation of params, then delegates | | `apps/web/src/lib/feed.ts` → `loadRankedFeed()` | Candidate generation, batched enrichment, pagination | -| `apps/web/src/lib/feed-ranking.ts` | Pure scoring + ordering (`scoreEvent`, `finalizeFeedOrder`) and every tunable constant | +| `apps/web/src/lib/feed-ranking.ts` | Pure scoring + ordering (`scoreEvent`, `sourceQuality`, `finalizeFeedOrder`, `compareSoonest`, `compareRecentlyPosted`) and every tunable constant | +| `apps/web/src/lib/event-visibility.ts` → `notFromHiddenOrg()` | The hidden-org filter shared by every discovery surface | +| `apps/web/src/lib/feed-ranking.test.ts`, `feed-controls.db.test.ts` | Unit tests of the scoring/sorts; end-to-end checks against a synced database | ## Pipeline -1. **Candidate generation** — which events get ranked at all (below). -2. **Batched enrichment** — tags, RSVP counts, view counts, friends attending, and the viewer's - own RSVP/save state for *every* candidate, one `inArray(...)` query per signal (six queries - total, independent of pool size). No per-event queries. -3. **Score + sort** — score desc, then soonest first, then event id. A total order, so the sort is - fully deterministic. -4. **Finalize one order over the whole list** — org-diversity cap and soon-event quota - (below) are applied to the complete ranked list, *before* pagination. -5. **Paginate** — the requested page is `finalOrder.slice(offset, offset + limit)`. -6. **Attendee rosters** — the full "N attending" list is loaded for the returned page only. - -`total` is the length of the finalized list, so it always agrees with what `offset`/`limit` can -reach. +1. **Candidate generation** — which events get ranked at all (below). Identical for every sort. +2. **Batched enrichment** ("For you" only) — tags, view counts and friends attending for + *every* candidate, one `inArray(...)` query per signal (three queries, independent of pool + size). No per-event queries. +3. **Score + sort** ("For you") — score desc, then soonest first, then event id. A total order, so + the sort is fully deterministic. Soonest / Recently posted use their own total orders instead. +4. **Finalize one order over the whole list** ("For you") — org-diversity cap and soon-event + quota (below) are applied to the complete ranked list, *before* pagination. +5. **Paginate** — the requested page is `finalOrder.slice(offset, offset + limit)`, minus any + org hidden since `asOf` (see "Hiding organizations"). +6. **Page details** — attendee rosters, the viewer's RSVP/save/follow state (and, for the plain + sorts, tags and friends) are loaded for the returned page only. + +`total` counts the finalized list minus orgs hidden since `asOf`, `nextOffset` is where the next +page starts and `remaining` how many visible events lie beyond it, so the numbers always agree +with what the client can reach. ## Candidate pool @@ -120,13 +176,14 @@ Scoring has to see more than one page — if only the 20 soonest events were sco relevant event further out could never outrank a weak one that merely happens sooner. **Stage 1 — calendar horizon.** Every published, visible, upcoming event matching the filters -within the next `CANDIDATE_HORIZON_DAYS` (45) days, rounded up to end of day, soonest first, up -to `CANDIDATE_POOL_CAP` (1000) rows. An explicit `dateRange` filter (`today`/`week`/`month`) +within the next `CANDIDATE_HORIZON_DAYS` (45) days, rounded up to end of day, not hosted by an +org the viewer hid, soonest first (newest-posted first for Recently posted), up to +`CANDIDATE_POOL_CAP` (1000) rows. An explicit `dateRange` filter (`today`/`week`/`month`) replaces the default horizon. At the expected scale (hundreds of events per semester) the cap never binds and stage 1 *is* the whole pool. Scoring 1000 candidates in memory is a few milliseconds; the batched enrichment queries stay well below Postgres' bind-parameter limit. -**Stage 2 — personalised expansion (only if stage 1 saturates).** If stage 1 returns exactly +**Stage 2 — personalised expansion (For you only, and only if stage 1 saturates).** If stage 1 returns exactly `CANDIDATE_POOL_CAP` rows, a second query over the same filters and horizon fetches up to `PERSONAL_CANDIDATE_CAP` (500) events *at or after* the stage-1 cutoff that carry a personal signal: hosted by an org the viewer follows/belongs to, RSVP'd by a friend, or tagged with one @@ -166,21 +223,40 @@ per org per page" wins over "3 soon events near the top". The window is fixed, independent of `limit`/`offset`, so page size never changes the order. +## Hiding organizations + +"Hide events from " (event card / event page menu, or the org page) writes a row to +`org_blocks`. Hidden orgs are removed by `notFromHiddenOrg()` in +`lib/event-visibility.ts` — never scored, in any sort — and the same filter is applied to the +map, similar events, friends' activity ("Friends going") and org suggestions. It is +deliberately *not* applied to the org's own page, direct event links, or your own RSVPs and +saves: hiding filters discovery, it doesn't make events unreachable. Hiding and following are +mutually exclusive (hiding unfollows, following unhides), and every write is idempotent. + ## Pagination and `asOf` Both rules above are part of one greedy pass (`finalizeFeedOrder`) over the full ranked list, producing a permutation of it (nothing added or dropped). Pages are plain slices of that array, -so within one ranking no event can appear on two pages or be skipped. +so within one ranking no event can appear on two pages or be skipped. The same holds for the +plain sorts, whose orders are total (ties broken by id). Scores depend on "now" (time proximity, recency, which events count as soon, the daily nudge -seed, and which events have already started). To keep page 2 a slice of the *same* ranking as +seed, and which events have already started). To keep page 2 a slice of the *same* ordering as page 1, every response includes `asOf` — the instant it was ranked for — and the client sends it -back with the next `offset`. The server reuses it as "now" if it is at most 30 minutes old -(otherwise it ranks fresh). Data that changes between requests (a new RSVP, a new event) can -still shift the order slightly; the client de-duplicates by id when appending as a safety net. +back with the next `offset` (the response's `nextOffset`) and the same sort. The server reuses +it as "now" if it is at most 30 minutes old (otherwise it ranks fresh). + +A pinned `asOf` also pins the viewer state that could reorder the list: only follows, hides +and events that existed at `asOf` are used for ordering. So following an org or a newly synced +event can't shift every later position by one mid-scroll (it takes effect on the next fresh +load). An org hidden *since* `asOf` is instead dropped from the output — from the page, `total` +and `remaining` — without re-ranking, so nothing else moves: the card disappears immediately +and page 2 continues exactly where page 1 ended. Remaining sources of drift (a new RSVP, an +unfollow of an older follow) can still shift the order slightly; the client de-duplicates by +id when appending as a safety net. All tunable constants (`CANDIDATE_HORIZON_DAYS`, `CANDIDATE_POOL_CAP`, `PERSONAL_CANDIDATE_CAP`, -`WEIGHTS`, `TIME_HALF_LIFE_DAYS`, `ORG_PAST_INTERACTION_AFFINITY`, `POPULARITY_VIEW_CAP`, +`WEIGHTS`, `TIME_HALF_LIFE_DAYS`, `ORG_PAST_INTERACTION_AFFINITY`, `SOURCE_QUALITY`, `POPULARITY_VIEW_CAP`, `ORG_DIVERSITY_CAP`, `ORG_DIVERSITY_WINDOW`, `SOON_WINDOW_DAYS`, `SOON_QUOTA`, `SOON_INJECTION_WINDOW`) live at the top of `apps/web/src/lib/feed-ranking.ts`. @@ -190,5 +266,10 @@ All tunable constants (`CANDIDATE_HORIZON_DAYS`, `CANDIDATE_POOL_CAP`, `PERSONAL - **No friends**: `friend_rsvp_score` is 0; the friends-attending lookup is skipped entirely. - **No org follows/memberships/past RSVPs**: `org_affinity` is 0. +- **No announcement data** (e.g. rows synced before `announcement_count` existed): the sync + backfills them on its next run, since it rewrites any row whose stored announcement signals + differ from InboxEngine's. + None of these throw or produce an empty feed — a brand-new user with zero signals still gets -a full feed, ranked by time proximity, recency, popularity, and the random nudge alone. +a full feed, ranked by time proximity, source quality, recency, popularity, and the random +nudge alone. diff --git a/packages/inbox-engine b/packages/inbox-engine index 4987399..31501ea 160000 --- a/packages/inbox-engine +++ b/packages/inbox-engine @@ -1 +1 @@ -Subproject commit 4987399233053644c689a800911daa9fcd132b22 +Subproject commit 31501ead277d039d0abfd7f69e55daf4b4d4f741