diff --git a/docs/queues/AnalyticsAggregation.md b/docs/queues/AnalyticsAggregation.md new file mode 100644 index 00000000..4a394dc1 --- /dev/null +++ b/docs/queues/AnalyticsAggregation.md @@ -0,0 +1,11 @@ +# AnalyticsAggregation BullMQ Queue + +The `AnalyticsAggregation` queue is responsible for compiling and aggregating raw telemetry and event data into materialized views or time-series rollup tables. + +## Architecture + +1. **Producer**: A cron-based job triggers the aggregation every hour. +2. **Worker**: The worker queries the raw event logs, aggregates counts and unique user metrics by time bucket (hourly, daily, weekly), and `UPSERT`s them into the `analytics_rollups` table. + +## Retention Policy +The raw event logs are kept for 7 days, after which they are pruned by the `DataCleanup` job. The aggregated data is kept indefinitely. diff --git a/docs/queues/LocationHistorySync.md b/docs/queues/LocationHistorySync.md new file mode 100644 index 00000000..347af0f2 --- /dev/null +++ b/docs/queues/LocationHistorySync.md @@ -0,0 +1,12 @@ +# LocationHistorySync BullMQ Queue + +The `LocationHistorySync` queue is responsible for flushing user location history from the Redis fast-path into the PostgreSQL database. + +## Architecture + +1. **Redis Cache**: High-frequency location updates are written to Redis sorted sets (ZSET) by the fast-path WebSocket handlers. +2. **Cron Producer**: A repeating BullMQ job runs every 5 minutes to trigger the sync process. +3. **Worker**: The worker pops the data from Redis, batches it into bulk `INSERT` statements using Drizzle ORM, and stores it in PostGIS. + +## Fault Tolerance +If the worker fails, the data remains in Redis and will be picked up on the next cron execution, ensuring zero data loss. diff --git a/docs/queues/NotificationQueue.md b/docs/queues/NotificationQueue.md new file mode 100644 index 00000000..3f12bf73 --- /dev/null +++ b/docs/queues/NotificationQueue.md @@ -0,0 +1,24 @@ +# NotificationQueue Configuration + +The `NotificationQueue` handles sending push notifications and emails to users asynchronously via BullMQ. + +## Architecture + +1. **Redis**: Used as the backing store for BullMQ. +2. **Worker**: A dedicated worker processes the `NotificationQueue` and interfaces with APNs/FCM or SendGrid. +3. **Producer**: The main Fastify server adds jobs to the queue when specific events occur (e.g. `MESSAGE_RECEIVED`, `ROOM_INVITE`). + +## Local Setup + +1. Start Redis: + ```bash + docker run -d --name redis-stack-server -p 6379:6379 redis/redis-stack-server:latest + ``` +2. Set `REDIS_URL` in your `.env` file: + ```env + REDIS_URL=redis://localhost:6379 + ``` +3. Start the worker process: + ```bash + npm run worker:start + ``` diff --git a/docs/queues/ThumbnailGeneration.md b/docs/queues/ThumbnailGeneration.md new file mode 100644 index 00000000..b7296384 --- /dev/null +++ b/docs/queues/ThumbnailGeneration.md @@ -0,0 +1,11 @@ +# ThumbnailGeneration BullMQ Queue + +The `ThumbnailGeneration` queue is responsible for offloading heavy image processing tasks (such as resizing user avatars and generating preview thumbnails for image attachments) to background workers. + +## Architecture + +1. **Producer**: Fastify route handles the direct file upload to S3-compatible storage, then pushes a job containing the `objectKey` to the BullMQ queue. +2. **Worker**: The worker downloads the source image, uses `sharp` to generate optimized thumbnails (e.g. 100x100 webp), uploads the derivatives back to storage, and updates the database record. + +## Concurrency +To prevent CPU starvation, the ThumbnailGeneration worker is configured with a concurrency limit of `5`. diff --git a/package-lock.json b/package-lock.json index e49d560b..5e770ae2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -99,7 +99,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -419,7 +418,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -458,7 +456,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -2912,7 +2909,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -3420,7 +3416,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", @@ -3955,7 +3950,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4511,7 +4505,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5014,8 +5007,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -5363,7 +5355,6 @@ "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", "license": "Apache-2.0", - "peer": true, "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", @@ -5807,7 +5798,6 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5981,7 +5971,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7656,7 +7645,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -8772,7 +8760,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9104,7 +9091,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -9117,7 +9103,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -10247,7 +10232,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10393,7 +10377,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -10995,7 +10978,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11530,7 +11512,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -11614,7 +11595,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" diff --git a/packages/client/src/app/(app)/profile/page.tsx b/packages/client/src/app/(app)/profile/page.tsx index ed676909..f75d5794 100644 --- a/packages/client/src/app/(app)/profile/page.tsx +++ b/packages/client/src/app/(app)/profile/page.tsx @@ -37,7 +37,29 @@ export default function ProfilePage() { } }, [mounted, isAuthenticated, router, user]); - if (!mounted || !user) return null; + if (!mounted || !user) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); + } const handleImageUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; diff --git a/packages/client/src/app/auth/loading.tsx b/packages/client/src/app/auth/loading.tsx new file mode 100644 index 00000000..4e2d55a8 --- /dev/null +++ b/packages/client/src/app/auth/loading.tsx @@ -0,0 +1,32 @@ +export default function Loading() { + return ( +
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+ +
+ +
+
+
+
+
+
+ ); +} diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index eecdbe00..8465ef32 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -12,6 +12,13 @@ import { dbTestRoutes } from "./routes/test-db"; import { authRoutes } from "./routes/auth"; import { roomRoutes } from "./routes/rooms"; import { waitlistRoutes } from "./routes/waitlist"; +import { userRoutes } from "./routes/users"; +import { groupRoutes } from "./routes/groups"; +import { messageRoutes } from "./routes/messages"; +import { locationRoutes } from "./routes/locations"; +import { circleRoutes } from "./routes/circles"; +import { postRoutes } from "./routes/posts"; +import { eventRoutes } from "./routes/events"; import { websocketRoutes } from "./websocket/wsHandler"; import fastifyJwt from "@fastify/jwt"; import fastifyCookie from "@fastify/cookie"; @@ -152,9 +159,17 @@ export const buildApp = async () => { // --- Routes --- app.register(healthRoutes, { prefix: "/api" }); app.register(dbTestRoutes, { prefix: "/api" }); - app.register(authRoutes, { prefix: "/api/auth" }); + app.register(authRoutes, { prefix: "/api/v1/auth" }); + app.register(groupRoutes, { prefix: "/api/v1/groups" }); + app.register(messageRoutes, { prefix: "/api/v1/messages" }); + app.register(locationRoutes, { prefix: "/api/v1/locations" }); + app.register(userRoutes, { prefix: "/api/v1/users" }); + app.register(circleRoutes, { prefix: "/api/v1/circles" }); + app.register(postRoutes, { prefix: "/api/v1/posts" }); + app.register(eventRoutes, { prefix: "/api/v1/events" }); app.register(roomRoutes, { prefix: "/api/rooms" }); app.register(waitlistRoutes, { prefix: "/api/waitlist" }); + app.register(userRoutes, { prefix: "/api/users" }); app.register(websocketRoutes, { prefix: "/ws" }); // --- Observability: Per-Route HTTP Latency Tracking --- diff --git a/packages/server/src/routes/circles.ts b/packages/server/src/routes/circles.ts new file mode 100644 index 00000000..3a8bf7d1 --- /dev/null +++ b/packages/server/src/routes/circles.ts @@ -0,0 +1,18 @@ +import { FastifyInstance } from "fastify"; +import { db } from "../db/index"; +import { circles } from "@intellicircle/shared"; +import { eq } from "drizzle-orm"; +import { createSuccessResponse, createErrorResponse } from "../utils/response"; + +export async function circleRoutes(app: FastifyInstance) { + app.get("/", async (request, reply) => { + try { + const results = await db.select().from(circles).limit(50); + return reply.send(createSuccessResponse(results)); + } catch (error) { + app.log.error(error); + // Fix unhandled promise rejection by properly catching and returning 500 + return reply.status(500).send(createErrorResponse("Internal server error", "INTERNAL_SERVER_ERROR")); + } + }); +} diff --git a/packages/server/src/routes/events.ts b/packages/server/src/routes/events.ts new file mode 100644 index 00000000..6c1ed170 --- /dev/null +++ b/packages/server/src/routes/events.ts @@ -0,0 +1,31 @@ +import { FastifyInstance } from "fastify"; +import { db } from "../db/index"; +import { events } from "@intellicircle/shared"; +import { createSuccessResponse, createErrorResponse } from "../utils/response"; + +export async function eventRoutes(app: FastifyInstance) { + /** + * @api {get} /api/v1/events/nearby Request Nearby Events + * @apiName GetNearbyEvents + * @apiGroup Events + * + * @apiParam {Number} lat Latitude of the location. + * @apiParam {Number} lng Longitude of the location. + * @apiParam {Number} [radius=10] Search radius in kilometers. + * + * @apiSuccess {Object[]} events List of events. + */ + app.get("/nearby", async (request, reply) => { + try { + // A simple implementation fetching first 50 events for scaffolding + const results = await db + .select() + .from(events) + .limit(50); + return reply.send(createSuccessResponse(results)); + } catch (error) { + app.log.error(error); + return reply.status(500).send(createErrorResponse("Internal server error", "INTERNAL_SERVER_ERROR")); + } + }); +} diff --git a/packages/server/src/routes/groups.ts b/packages/server/src/routes/groups.ts new file mode 100644 index 00000000..92e84d34 --- /dev/null +++ b/packages/server/src/routes/groups.ts @@ -0,0 +1,8 @@ +import { FastifyInstance } from "fastify"; +import { createSuccessResponse, createErrorResponse } from "../utils/response"; + +export async function groupRoutes(app: FastifyInstance) { + app.get("/", async (request, reply) => { + return reply.send(createSuccessResponse([])); + }); +} diff --git a/packages/server/src/routes/locations.ts b/packages/server/src/routes/locations.ts new file mode 100644 index 00000000..a14bb69a --- /dev/null +++ b/packages/server/src/routes/locations.ts @@ -0,0 +1,8 @@ +import { FastifyInstance } from "fastify"; +import { createSuccessResponse, createErrorResponse } from "../utils/response"; + +export async function locationRoutes(app: FastifyInstance) { + app.get("/", async (request, reply) => { + return reply.send(createSuccessResponse([])); + }); +} diff --git a/packages/server/src/routes/messages.ts b/packages/server/src/routes/messages.ts new file mode 100644 index 00000000..928f8a8b --- /dev/null +++ b/packages/server/src/routes/messages.ts @@ -0,0 +1,8 @@ +import { FastifyInstance } from "fastify"; +import { createSuccessResponse, createErrorResponse } from "../utils/response"; + +export async function messageRoutes(app: FastifyInstance) { + app.get("/", async (request, reply) => { + return reply.send(createSuccessResponse([])); + }); +} diff --git a/packages/server/src/routes/posts.ts b/packages/server/src/routes/posts.ts new file mode 100644 index 00000000..af442e4d --- /dev/null +++ b/packages/server/src/routes/posts.ts @@ -0,0 +1,28 @@ +import { FastifyInstance } from "fastify"; +import { db } from "../db/index"; +import { posts } from "@intellicircle/shared"; +import { eq } from "drizzle-orm"; +import { createSuccessResponse, createErrorResponse } from "../utils/response"; + +export async function postRoutes(app: FastifyInstance) { + app.get("/:id", async (request, reply) => { + const { id } = request.params as { id: string }; + + try { + const results = await db + .select() + .from(posts) + .where(eq(posts.id, parseInt(id, 10))) + .limit(1); + + if (results.length === 0) { + return reply.status(404).send(createErrorResponse("Post not found", "POST_NOT_FOUND")); + } + + return reply.send(createSuccessResponse(results[0])); + } catch (error) { + app.log.error(error); + return reply.status(500).send(createErrorResponse("Internal server error", "INTERNAL_SERVER_ERROR")); + } + }); +} diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts new file mode 100644 index 00000000..a5d9f4b5 --- /dev/null +++ b/packages/server/src/routes/users.ts @@ -0,0 +1,34 @@ +import { FastifyInstance } from "fastify"; +import { db } from "../db/index"; +import { users } from "@intellicircle/shared"; +import { eq } from "drizzle-orm"; +import { createSuccessResponse, createErrorResponse } from "../utils/response"; + +export async function userRoutes(app: FastifyInstance) { + // 1. Fetch User details by username + app.get("/:username", async (request, reply) => { + const { username } = request.params as { username: string }; + + try { + const userResults = await db + .select({ + id: users.id, + username: users.username, + role: users.role, + createdAt: users.createdAt, + }) + .from(users) + .where(eq(users.username, username)) + .limit(1); + + if (userResults.length === 0) { + return reply.status(404).send(createErrorResponse("User not found", "USER_NOT_FOUND")); + } + + return reply.send(createSuccessResponse(userResults[0])); + } catch (error) { + app.log.error(error); + return reply.status(500).send(createErrorResponse("Internal server error", "INTERNAL_SERVER_ERROR")); + } + }); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 62ef36e6..686fbd9e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,15 +1 @@ -export { - waitlist, - users, - chatRooms, - messages, - participants, - authAuditLogs, - insertWaitlistSchema, - selectWaitlistSchema, - anonymousAuthSchema, - upgradeAuthSchema, - loginAuthSchema, - createRoomSchema, - nearbyRoomsQuerySchema -} from "./schema"; +export * from "./schema"; diff --git a/packages/shared/src/schema.ts b/packages/shared/src/schema.ts index 097042a3..0e6fa7fa 100644 --- a/packages/shared/src/schema.ts +++ b/packages/shared/src/schema.ts @@ -8,6 +8,7 @@ import { integer, uniqueIndex, index, + doublePrecision, } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; import { z } from "zod"; @@ -94,6 +95,20 @@ export const participants = pgTable( } ); +// --- WebSocket Schemas --- +export const userJoinedPayloadSchema = z.object({ + userId: z.number(), + roomId: z.number(), + timestamp: z.date().default(() => new Date()), +}); + +// --- Location Schemas --- +export const locationCreationPayloadSchema = z.object({ + label: z.string().min(1).max(100, "Label must be less than 100 characters"), + lat: z.number().min(-90).max(90, "Latitude must be between -90 and 90"), + lng: z.number().min(-180).max(180, "Longitude must be between -180 and 180"), +}); + // Waitlist Schemas export const insertWaitlistSchema = z.object({ email: z.string().email(), @@ -153,3 +168,29 @@ export const nearbyRoomsQuerySchema = z.object({ interests: z.union([z.string(), z.array(z.string())]).optional() .transform(val => Array.isArray(val) ? val : (val ? [val] : [])) }); + + +export const circles = pgTable("circles", { + id: serial("id").primaryKey(), + name: varchar("name", { length: 256 }).notNull(), + description: text("description"), + creatorId: integer("creator_id").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow().$onUpdate(() => new Date()), +}); + +export const posts = pgTable("posts", { + id: serial("id").primaryKey(), + content: text("content").notNull(), + authorId: integer("author_id").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), +}); + +export const events = pgTable("events", { + id: serial("id").primaryKey(), + name: varchar("name", { length: 256 }).notNull(), + latitude: doublePrecision("latitude").notNull(), + longitude: doublePrecision("longitude").notNull(), + date: timestamp("date").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), +});