From 2a516e8b46e85d6c79dac5bfac9308f44fc2d515 Mon Sep 17 00:00:00 2001 From: Amr Mousa Date: Tue, 7 Jul 2026 06:32:07 +0300 Subject: [PATCH 1/3] feat: add user profile page and improve Swagger API documentation --- apps/api/docs/swagger.json | 211 +++++++++++++++++++++++++++++++++++++ apps/web/angular.json | 5 + apps/web/bun.lock | 52 +++++++++ apps/web/package.json | 8 ++ 4 files changed, 276 insertions(+) diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index 3891c95..b2f5bc0 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -548,6 +548,40 @@ } } }, +<<<<<<< HEAD +======= + "/api/profile": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the logged-in user's full profile: bio info, coding metrics, activity heatmap, problem-solving gauge, achievements, recent activity, and personal-info completion.", + "produces": [ + "application/json" + ], + "tags": [ + "profile" + ], + "summary": "Get full profile", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/helpers.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/helpers.APIResponse" + } + } + } + } + }, +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "/api/settings/account": { "post": { "security": [ @@ -815,6 +849,43 @@ } } }, +<<<<<<< HEAD +======= + "models.Achievement": { + "type": "object", + "properties": { + "badgeClass": { + "type": "string" + }, + "description": { + "type": "string" + }, + "earnedAt": { + "type": "string" + }, + "key": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "models.ActivityLogItem": { + "type": "object", + "properties": { + "at": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "text": { + "type": "string" + } + } + }, +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "models.Heartbeat": { "type": "object", "properties": { @@ -852,6 +923,146 @@ "type": "string" } } +<<<<<<< HEAD +======= + }, + "models.HeatmapCell": { + "type": "object", + "properties": { + "date": { + "type": "string" + }, + "level": { + "type": "integer" + } + } + }, + "models.ProfileInfoField": { + "type": "object", + "properties": { + "completed": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "label": { + "type": "string" + } + } + }, + "models.ProfileMetric": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "label": { + "type": "string" + }, + "positive": { + "type": "boolean" + }, + "sub": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "models.ProfileResponse": { + "type": "object", + "properties": { + "achievements": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Achievement" + } + }, + "attempting": { + "type": "integer" + }, + "avatarUrl": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "completionPercent": { + "type": "integer" + }, + "email": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "heatmap": { + "type": "array", + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.HeatmapCell" + } + } + }, + "infoFields": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ProfileInfoField" + } + }, + "joinDate": { + "type": "string" + }, + "lastActive": { + "type": "string" + }, + "location": { + "type": "string" + }, + "maxStreak": { + "type": "integer" + }, + "memberFor": { + "type": "string" + }, + "metrics": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ProfileMetric" + } + }, + "recentActivity": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ActivityLogItem" + } + }, + "role": { + "type": "string" + }, + "solved": { + "type": "integer" + }, + "timeZone": { + "type": "string" + }, + "totalActiveDays": { + "type": "integer" + }, + "totalProblems": { + "type": "integer" + }, + "university": { + "type": "string" + }, + "username": { + "type": "string" + } + } +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) } }, "securityDefinitions": { diff --git a/apps/web/angular.json b/apps/web/angular.json index 827d954..b4263e9 100644 --- a/apps/web/angular.json +++ b/apps/web/angular.json @@ -2,7 +2,12 @@ "$schema": "./node_modules/@angular/cli/lib/config/schema.json", "version": 1, "cli": { +<<<<<<< HEAD "packageManager": "bun" +======= + "packageManager": "bun", + "analytics": "b6ac57da-2b69-4157-a25b-75012047082b" +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) }, "newProjectRoot": "projects", "projects": { diff --git a/apps/web/bun.lock b/apps/web/bun.lock index a6a19ab..97c8cd6 100644 --- a/apps/web/bun.lock +++ b/apps/web/bun.lock @@ -3,7 +3,11 @@ "configVersion": 1, "workspaces": { "": { +<<<<<<< HEAD "name": "web", +======= + "name": "seismic-web", +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "dependencies": { "@angular/common": "^22.0.0", "@angular/compiler": "^22.0.0", @@ -11,6 +15,10 @@ "@angular/forms": "^22.0.0", "@angular/platform-browser": "^22.0.0", "@angular/router": "^22.0.0", +<<<<<<< HEAD +======= + "fdir": "^6.5.0", +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "lucide-angular": "^1.0.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", @@ -435,6 +443,7 @@ "@vitejs/plugin-basic-ssl": ["@vitejs/plugin-basic-ssl@2.3.0", "", { "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ=="], +<<<<<<< HEAD "@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], "@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], @@ -448,6 +457,21 @@ "@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], "@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], +======= + "@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="], + + "@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="], + + "@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="], + + "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="], +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="], @@ -473,7 +497,11 @@ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], +<<<<<<< HEAD "baseline-browser-mapping": ["baseline-browser-mapping@2.10.41", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A=="], +======= + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.42", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q=="], +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "beasties": ["beasties@0.4.2", "", { "dependencies": { "css-select": "^6.0.0", "css-what": "^7.0.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "htmlparser2": "^10.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.49", "postcss-media-query-parser": "^0.2.3", "postcss-safe-parser": "^7.0.1" } }, "sha512-NvcGjG/7AVUAfRbvrJmHunDQS9uHnE6Q/7AkaPr8oKE8HjOlpjRG5075z/th2Tmlezk3VlaaS8+X9I1RwHJMQw=="], @@ -485,7 +513,11 @@ "brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="], +<<<<<<< HEAD "browserslist": ["browserslist@4.28.4", "", { "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", "electron-to-chromium": "^1.5.376", "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw=="], +======= + "browserslist": ["browserslist@4.28.5", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001800", "electron-to-chromium": "^1.5.387", "node-releases": "^2.0.50", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ=="], +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], @@ -497,7 +529,11 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], +<<<<<<< HEAD "caniuse-lite": ["caniuse-lite@1.0.30001800", "", {}, "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA=="], +======= + "caniuse-lite": ["caniuse-lite@1.0.30001802", "", {}, "sha512-vmv8ub2xwTNmljSKf82mtCk5JH7hC+YgzLj3P5zotvA0tPQ9016tdNNOG8WRca1IxOnhSsivB+J0z5FeE5LOUw=="], +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], @@ -653,7 +689,11 @@ "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], +<<<<<<< HEAD "hono": ["hono@4.12.27", "", {}, "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q=="], +======= + "hono": ["hono@4.12.28", "", {}, "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA=="], +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], @@ -993,7 +1033,11 @@ "vite": ["vite@7.3.5", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww=="], +<<<<<<< HEAD "vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], +======= + "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="], +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], @@ -1045,8 +1089,11 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], +<<<<<<< HEAD "@inquirer/prompts/@inquirer/confirm": ["@inquirer/confirm@6.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ=="], +======= +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "@npmcli/agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "@npmcli/agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], @@ -1067,6 +1114,11 @@ "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], +<<<<<<< HEAD +======= + "estree-walker/@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], diff --git a/apps/web/package.json b/apps/web/package.json index 1ce6a14..6c80486 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,11 @@ "test": "ng test" }, "private": true, +<<<<<<< HEAD "packageManager": "bun@1.3.14", +======= + "packageManager": "bun@1.3.14" , +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "dependencies": { "@angular/common": "^22.0.0", "@angular/compiler": "^22.0.0", @@ -17,6 +21,10 @@ "@angular/forms": "^22.0.0", "@angular/platform-browser": "^22.0.0", "@angular/router": "^22.0.0", +<<<<<<< HEAD +======= + "fdir": "^6.5.0", +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "lucide-angular": "^1.0.0", "rxjs": "~7.8.0", "tslib": "^2.3.0" From 670b0eb0e3f81d0355a74dd8d9694a82dfeb9649 Mon Sep 17 00:00:00 2001 From: Amr Mousa Date: Tue, 7 Jul 2026 23:34:25 +0300 Subject: [PATCH 2/3] updates to profile schema, refresh tokens, and stats caching - Add UpdateProfileStats to upsert cached aggregate stats after session processing - Add GetProfileStats model function for reading cached profile stats - Add RevokeAllUserRefreshTokens and CleanupExpiredRefreshTokens for token management - Add POST /api/auth/logout endpoint to revoke refresh token and clear cookie - Add periodic cleanup of expired refresh tokens on token refresh - Populate user_profile_stats table from session processor background job - Extend User model with all profile fields from migrations --- apps/api/db/migrations/005_extend_users.sql | 10 ++ apps/api/db/migrations/006_profile_stats.sql | 10 ++ apps/api/db/migrations/007_achievements.sql | 18 +++ apps/api/db/migrations/008_activity_log.sql | 15 +++ .../009_problem_stats_placeholder.sql | 14 +++ apps/api/db/seed_profile.sql | 56 +++++++++ apps/api/docs/swagger.json | 15 +-- apps/api/handlers/auth.go | 34 ++++++ apps/api/models/profile.go | 109 ++++++++++++++++++ apps/api/models/refresh_token.go | 22 ++++ apps/api/models/user.go | 49 ++++---- apps/api/routes/routes.go | 1 + apps/api/services/session_processor.go | 12 ++ 13 files changed, 336 insertions(+), 29 deletions(-) create mode 100644 apps/api/db/migrations/005_extend_users.sql create mode 100644 apps/api/db/migrations/006_profile_stats.sql create mode 100644 apps/api/db/migrations/007_achievements.sql create mode 100644 apps/api/db/migrations/008_activity_log.sql create mode 100644 apps/api/db/migrations/009_problem_stats_placeholder.sql create mode 100644 apps/api/db/seed_profile.sql create mode 100644 apps/api/models/profile.go diff --git a/apps/api/db/migrations/005_extend_users.sql b/apps/api/db/migrations/005_extend_users.sql new file mode 100644 index 0000000..784a010 --- /dev/null +++ b/apps/api/db/migrations/005_extend_users.sql @@ -0,0 +1,10 @@ +-- Extends the users table with profile-specific fields not yet present +ALTER TABLE users + ADD COLUMN first_name VARCHAR(50), + ADD COLUMN role VARCHAR(50), + ADD COLUMN location VARCHAR(100), + ADD COLUMN university VARCHAR(100), + ADD COLUMN time_zone VARCHAR(50), + ADD COLUMN last_active_at TIMESTAMPTZ, + ADD COLUMN gender VARCHAR(50), + ADD COLUMN languages TEXT[]; -- Simple text array for display purposes on the profile diff --git a/apps/api/db/migrations/006_profile_stats.sql b/apps/api/db/migrations/006_profile_stats.sql new file mode 100644 index 0000000..96229b6 --- /dev/null +++ b/apps/api/db/migrations/006_profile_stats.sql @@ -0,0 +1,10 @@ +-- Cached aggregate coding data to avoid heavy calculations on every profile view +-- This will be populated and updated by the background session-processing job +CREATE TABLE user_profile_stats ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + total_coding_seconds BIGINT DEFAULT 0, + total_active_days INTEGER DEFAULT 0, + current_streak INTEGER DEFAULT 0, + max_streak INTEGER DEFAULT 0, + updated_at TIMESTAMPTZ DEFAULT now() +); diff --git a/apps/api/db/migrations/007_achievements.sql b/apps/api/db/migrations/007_achievements.sql new file mode 100644 index 0000000..919d055 --- /dev/null +++ b/apps/api/db/migrations/007_achievements.sql @@ -0,0 +1,18 @@ +-- A richer achievement system linking users to specific badges and their metadata +CREATE TABLE achievement_types ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key VARCHAR(50) UNIQUE NOT NULL, + title VARCHAR(100) NOT NULL, + description TEXT NOT NULL, + badge_class VARCHAR(50) NOT NULL +); + +CREATE TABLE user_achievements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + achievement_type_id UUID REFERENCES achievement_types(id) ON DELETE CASCADE, + earned_at TIMESTAMPTZ DEFAULT now(), + UNIQUE (user_id, achievement_type_id) +); + +CREATE INDEX idx_user_achievements_user_id ON user_achievements(user_id); diff --git a/apps/api/db/migrations/008_activity_log.sql b/apps/api/db/migrations/008_activity_log.sql new file mode 100644 index 0000000..22747d3 --- /dev/null +++ b/apps/api/db/migrations/008_activity_log.sql @@ -0,0 +1,15 @@ +-- Activity log for the recent activity feed on a user's profile +CREATE TABLE activity_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + kind VARCHAR(50) NOT NULL, + text TEXT NOT NULL, + metadata JSONB, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX idx_activity_log_user_time ON activity_log(user_id, created_at DESC); + +-- Also add index on sessions and heartbeats as requested in the plan +CREATE INDEX idx_sessions_user_time ON sessions(user_id, start_time); +CREATE INDEX idx_heartbeats_user_time ON heartbeats(user_id, time); diff --git a/apps/api/db/migrations/009_problem_stats_placeholder.sql b/apps/api/db/migrations/009_problem_stats_placeholder.sql new file mode 100644 index 0000000..442a184 --- /dev/null +++ b/apps/api/db/migrations/009_problem_stats_placeholder.sql @@ -0,0 +1,14 @@ +-- Placeholder table for problem-solving stats +-- WARNING: There is currently no problem-solving or contest domain in Seismic. +-- This schema is intentionally introduced only to satisfy the future Profile API +-- and remains unused until such a feature exists. Ratings and contributions are +-- strictly placeholders and not derived from any existing system. +CREATE TABLE user_problem_stats ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + solved_count INTEGER DEFAULT 0, + total_problems INTEGER DEFAULT 0, + attempting_count INTEGER DEFAULT 0, + rating INTEGER DEFAULT 0, + contribution_points INTEGER DEFAULT 0, + updated_at TIMESTAMPTZ DEFAULT now() +); diff --git a/apps/api/db/seed_profile.sql b/apps/api/db/seed_profile.sql new file mode 100644 index 0000000..8a2dd1b --- /dev/null +++ b/apps/api/db/seed_profile.sql @@ -0,0 +1,56 @@ +-- Ensure we have a user to attach these to +DO $$ +DECLARE + test_user_id UUID; + ach_id UUID; +BEGIN + -- Get or create a test user + SELECT id INTO test_user_id FROM users WHERE username = 'testuser' LIMIT 1; + + IF NOT FOUND THEN + INSERT INTO users (username, email, display_name, first_name, role, location, university, bio, time_zone, gender, languages) + VALUES ('testuser', 'testuser@example.com', 'Test User', 'Test', 'Developer', 'San Francisco, CA', 'Tech University', 'Just a test user building cool things.', 'America/Los_Angeles', 'Male', ARRAY['Go', 'TypeScript', 'SQL']) + RETURNING id INTO test_user_id; + END IF; + + -- Upsert Profile Stats + INSERT INTO user_profile_stats (user_id, total_coding_seconds, total_active_days, current_streak, max_streak) + VALUES (test_user_id, 360000, 45, 12, 15) + ON CONFLICT (user_id) DO UPDATE SET + total_coding_seconds = EXCLUDED.total_coding_seconds, + total_active_days = EXCLUDED.total_active_days, + current_streak = EXCLUDED.current_streak, + max_streak = EXCLUDED.max_streak; + + -- Upsert Problem Stats (Placeholder) + INSERT INTO user_problem_stats (user_id, solved_count, total_problems, attempting_count, rating, contribution_points) + VALUES (test_user_id, 150, 500, 3, 1450, 200) + ON CONFLICT (user_id) DO UPDATE SET + solved_count = EXCLUDED.solved_count, + total_problems = EXCLUDED.total_problems, + attempting_count = EXCLUDED.attempting_count, + rating = EXCLUDED.rating, + contribution_points = EXCLUDED.contribution_points; + + -- Seed Achievement Types + INSERT INTO achievement_types (key, title, description, badge_class) + VALUES + ('first_blood', 'First Blood', 'Logged your first session.', 'gold'), + ('night_owl', 'Night Owl', 'Coded past 2 AM.', 'silver'), + ('streak_7', '7-Day Streak', 'Maintained a 7 day streak.', 'bronze') + ON CONFLICT (key) DO NOTHING; + + -- Give the user an achievement + SELECT id INTO ach_id FROM achievement_types WHERE key = 'first_blood' LIMIT 1; + IF ach_id IS NOT NULL THEN + INSERT INTO user_achievements (user_id, achievement_type_id) + VALUES (test_user_id, ach_id) + ON CONFLICT (user_id, achievement_type_id) DO NOTHING; + END IF; + + -- Seed Activity Log + INSERT INTO activity_log (user_id, kind, text) + VALUES (test_user_id, 'achievement', 'Earned the First Blood badge!'), + (test_user_id, 'streak', 'Reached a 12 day streak!'); + +END $$; diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index b2f5bc0..88a3fea 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -548,8 +548,7 @@ } } }, -<<<<<<< HEAD -======= + "/api/profile": { "get": { "security": [ @@ -581,7 +580,7 @@ } } }, ->>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) + "/api/settings/account": { "post": { "security": [ @@ -849,8 +848,7 @@ } } }, -<<<<<<< HEAD -======= + "models.Achievement": { "type": "object", "properties": { @@ -885,7 +883,7 @@ } } }, ->>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) + "models.Heartbeat": { "type": "object", "properties": { @@ -923,8 +921,7 @@ "type": "string" } } -<<<<<<< HEAD -======= + }, "models.HeatmapCell": { "type": "object", @@ -1062,7 +1059,7 @@ "type": "string" } } ->>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) + } }, "securityDefinitions": { diff --git a/apps/api/handlers/auth.go b/apps/api/handlers/auth.go index b1f96f9..42c0484 100644 --- a/apps/api/handlers/auth.go +++ b/apps/api/handlers/auth.go @@ -237,11 +237,45 @@ func (h *AuthHandler) RefreshAccessToken(c *fiber.Ctx) error { return helpers.Error(c, fiber.StatusInternalServerError, "Something went wrong") } + // Clean up expired tokens periodically (non-blocking) + _, _ = models.CleanupExpiredRefreshTokens(ctx, h.Pool) + return helpers.Success(c, "Token refreshed", fiber.Map{ "accessToken": accessToken, }) } +// Logout godoc +// @Summary Log out current user +// @Description Revokes the refresh token cookie and clears it from the browser. +// @Tags auth +// @Produce json +// @Success 200 {object} helpers.APIResponse +// @Router /api/auth/logout [post] +func (h *AuthHandler) Logout(c *fiber.Ctx) error { + rawToken := c.Cookies("refresh_token") + if rawToken != "" { + ctx := c.Context() + stored, err := models.FindValidRefreshToken(ctx, h.Pool, rawToken) + if err == nil && stored != nil { + _ = models.RevokeRefreshToken(ctx, h.Pool, stored.ID) + } + } + + // Clear the cookie + c.Cookie(&fiber.Cookie{ + Name: "refresh_token", + Value: "", + HTTPOnly: true, + Secure: strings.HasPrefix(h.EmailCfg.AppURL, "https"), + SameSite: "None", + Path: "/api/auth", + Expires: time.Now().Add(-1 * time.Hour), + }) + + return helpers.Success(c, "Logged out", nil) +} + // setRefreshTokenCookie sets the refresh token as an httpOnly // cookie so client-side JavaScript can never read it directly. // Secure is disabled for local http testing, enabled in diff --git a/apps/api/models/profile.go b/apps/api/models/profile.go new file mode 100644 index 0000000..b7e5d82 --- /dev/null +++ b/apps/api/models/profile.go @@ -0,0 +1,109 @@ +package models + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// UserProfileStats caches aggregate coding data for the profile page +type UserProfileStats struct { + UserID string `json:"userId"` + TotalCodingSeconds int64 `json:"totalCodingSeconds"` + TotalActiveDays int `json:"totalActiveDays"` + CurrentStreak int `json:"currentStreak"` + MaxStreak int `json:"maxStreak"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// GetProfileStats retrieves cached profile stats for a user. +func GetProfileStats(ctx context.Context, pool *pgxpool.Pool, userID string) (*UserProfileStats, error) { + var s UserProfileStats + err := pool.QueryRow(ctx, ` + SELECT user_id, total_coding_seconds, total_active_days, current_streak, max_streak, updated_at + FROM user_profile_stats + WHERE user_id = $1 + `, userID).Scan(&s.UserID, &s.TotalCodingSeconds, &s.TotalActiveDays, &s.CurrentStreak, &s.MaxStreak, &s.UpdatedAt) + if err != nil { + return nil, err + } + return &s, nil +} + +// UpdateProfileStats recalculates aggregate stats from the sessions table +// and upserts them into user_profile_stats for the given user. +func UpdateProfileStats(ctx context.Context, pool *pgxpool.Pool, userID string) error { + var totalSeconds int64 + _ = pool.QueryRow(ctx, ` + SELECT COALESCE(SUM(duration_seconds), 0) FROM sessions WHERE user_id = $1 + `, userID).Scan(&totalSeconds) + + var totalDays int + _ = pool.QueryRow(ctx, ` + SELECT COUNT(DISTINCT start_time::date) FROM sessions WHERE user_id = $1 + `, userID).Scan(&totalDays) + + currentStreak, _ := GetCurrentStreak(ctx, pool, userID) + + var prevMax int + _ = pool.QueryRow(ctx, ` + SELECT COALESCE(max_streak, 0) FROM user_profile_stats WHERE user_id = $1 + `, userID).Scan(&prevMax) + + maxStreak := prevMax + if currentStreak > maxStreak { + maxStreak = currentStreak + } + + _, err := pool.Exec(ctx, ` + INSERT INTO user_profile_stats (user_id, total_coding_seconds, total_active_days, current_streak, max_streak, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT (user_id) DO UPDATE SET + total_coding_seconds = EXCLUDED.total_coding_seconds, + total_active_days = EXCLUDED.total_active_days, + current_streak = EXCLUDED.current_streak, + max_streak = GREATEST(user_profile_stats.max_streak, EXCLUDED.current_streak), + updated_at = EXCLUDED.updated_at + `, userID, totalSeconds, totalDays, currentStreak, maxStreak) + + return err +} + +// AchievementType represents a badge that can be earned +type AchievementType struct { + ID string `json:"id"` + Key string `json:"key"` + Title string `json:"title"` + Description string `json:"description"` + BadgeClass string `json:"badgeClass"` +} + +// UserAchievement tracks which user earned which badge and when +type UserAchievement struct { + ID string `json:"id"` + UserID string `json:"userId"` + AchievementTypeID string `json:"achievementTypeId"` + EarnedAt time.Time `json:"earnedAt"` +} + +// ActivityLog represents an entry in the user's recent activity feed +type ActivityLog struct { + ID string `json:"id"` + UserID string `json:"userId"` + Kind string `json:"kind"` + Text string `json:"text"` + Metadata any `json:"metadata,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +// UserProblemStats is a placeholder for future problem-solving features +type UserProblemStats struct { + UserID string `json:"userId"` + SolvedCount int `json:"solvedCount"` + TotalProblems int `json:"totalProblems"` + AttemptingCount int `json:"attemptingCount"` + Rating int `json:"rating"` + ContributionPoints int `json:"contributionPoints"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/apps/api/models/refresh_token.go b/apps/api/models/refresh_token.go index 169d2ea..3c5e6e4 100644 --- a/apps/api/models/refresh_token.go +++ b/apps/api/models/refresh_token.go @@ -95,3 +95,25 @@ func RevokeRefreshToken(ctx context.Context, pool *pgxpool.Pool, id string) erro `, id) return err } + +// RevokeAllUserRefreshTokens revokes every non-revoked token for a user. +// Useful when rotating all tokens (e.g. password change, account compromise). +func RevokeAllUserRefreshTokens(ctx context.Context, pool *pgxpool.Pool, userID string) error { + _, err := pool.Exec(ctx, ` + UPDATE refresh_tokens SET revoked = true + WHERE user_id = $1 AND revoked = false + `, userID) + return err +} + +// CleanupExpiredRefreshTokens deletes all expired tokens from the database. +// Should be called periodically to prevent the table from growing unbounded. +func CleanupExpiredRefreshTokens(ctx context.Context, pool *pgxpool.Pool) (int64, error) { + res, err := pool.Exec(ctx, ` + DELETE FROM refresh_tokens WHERE expires_at < NOW() + `) + if err != nil { + return 0, err + } + return res.RowsAffected(), nil +} diff --git a/apps/api/models/user.go b/apps/api/models/user.go index 7059d25..f26fcf3 100644 --- a/apps/api/models/user.go +++ b/apps/api/models/user.go @@ -15,11 +15,20 @@ type User struct { Username string `json:"username"` Email string `json:"email"` APIKey string `json:"apiKey"` + DisplayName *string `json:"displayName"` + FirstName *string `json:"firstName"` + Role *string `json:"role"` + Location *string `json:"location"` + University *string `json:"university"` Country *string `json:"country"` Bio *string `json:"bio"` Website *string `json:"website"` + TimeZone *string `json:"timeZone"` + Gender *string `json:"gender"` + Languages []string `json:"languages"` AvatarURL *string `json:"avatarUrl"` AvatarPublicID *string `json:"-"` + LastActiveAt *time.Time `json:"lastActiveAt"` CreatedAt time.Time `json:"createdAt"` DeletedAt *time.Time `json:"-"` } @@ -32,13 +41,13 @@ func FindUserByEmail(ctx context.Context, pool *pgxpool.Pool, email string) (*Us var u User err := pool.QueryRow(ctx, ` - SELECT id, username, email, api_key, country, bio, website, - avatar_url, avatar_public_id, created_at, deleted_at + SELECT id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, + time_zone, gender, languages, avatar_url, avatar_public_id, last_active_at, created_at, deleted_at FROM users WHERE email = $1 AND deleted_at IS NULL `, email).Scan( - &u.ID, &u.Username, &u.Email, &u.APIKey, &u.Country, &u.Bio, - &u.Website, &u.AvatarURL, &u.AvatarPublicID, &u.CreatedAt, &u.DeletedAt, + &u.ID, &u.Username, &u.Email, &u.APIKey, &u.DisplayName, &u.FirstName, &u.Role, &u.Location, &u.University, &u.Country, &u.Bio, + &u.Website, &u.TimeZone, &u.Gender, &u.Languages, &u.AvatarURL, &u.AvatarPublicID, &u.LastActiveAt, &u.CreatedAt, &u.DeletedAt, ) if errors.Is(err, pgx.ErrNoRows) { @@ -58,13 +67,13 @@ func FindUserByUsername(ctx context.Context, pool *pgxpool.Pool, username string var u User err := pool.QueryRow(ctx, ` - SELECT id, username, email, api_key, country, bio, website, - avatar_url, avatar_public_id, created_at, deleted_at + SELECT id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, + time_zone, gender, languages, avatar_url, avatar_public_id, last_active_at, created_at, deleted_at FROM users WHERE username = $1 AND deleted_at IS NULL `, username).Scan( - &u.ID, &u.Username, &u.Email, &u.APIKey, &u.Country, &u.Bio, - &u.Website, &u.AvatarURL, &u.AvatarPublicID, &u.CreatedAt, &u.DeletedAt, + &u.ID, &u.Username, &u.Email, &u.APIKey, &u.DisplayName, &u.FirstName, &u.Role, &u.Location, &u.University, &u.Country, &u.Bio, + &u.Website, &u.TimeZone, &u.Gender, &u.Languages, &u.AvatarURL, &u.AvatarPublicID, &u.LastActiveAt, &u.CreatedAt, &u.DeletedAt, ) if errors.Is(err, pgx.ErrNoRows) { @@ -82,13 +91,13 @@ func FindUserByID(ctx context.Context, pool *pgxpool.Pool, id string) (*User, er var u User err := pool.QueryRow(ctx, ` - SELECT id, username, email, api_key, country, bio, website, - avatar_url, avatar_public_id, created_at, deleted_at + SELECT id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, + time_zone, gender, languages, avatar_url, avatar_public_id, last_active_at, created_at, deleted_at FROM users WHERE id = $1 AND deleted_at IS NULL `, id).Scan( - &u.ID, &u.Username, &u.Email, &u.APIKey, &u.Country, &u.Bio, - &u.Website, &u.AvatarURL, &u.AvatarPublicID, &u.CreatedAt, &u.DeletedAt, + &u.ID, &u.Username, &u.Email, &u.APIKey, &u.DisplayName, &u.FirstName, &u.Role, &u.Location, &u.University, &u.Country, &u.Bio, + &u.Website, &u.TimeZone, &u.Gender, &u.Languages, &u.AvatarURL, &u.AvatarPublicID, &u.LastActiveAt, &u.CreatedAt, &u.DeletedAt, ) if errors.Is(err, pgx.ErrNoRows) { @@ -107,13 +116,13 @@ func FindUserByAPIKey(ctx context.Context, pool *pgxpool.Pool, apiKey string) (* var u User err := pool.QueryRow(ctx, ` - SELECT id, username, email, api_key, country, bio, website, - avatar_url, avatar_public_id, created_at, deleted_at + SELECT id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, + time_zone, gender, languages, avatar_url, avatar_public_id, last_active_at, created_at, deleted_at FROM users WHERE api_key = $1 AND deleted_at IS NULL `, apiKey).Scan( - &u.ID, &u.Username, &u.Email, &u.APIKey, &u.Country, &u.Bio, - &u.Website, &u.AvatarURL, &u.AvatarPublicID, &u.CreatedAt, &u.DeletedAt, + &u.ID, &u.Username, &u.Email, &u.APIKey, &u.DisplayName, &u.FirstName, &u.Role, &u.Location, &u.University, &u.Country, &u.Bio, + &u.Website, &u.TimeZone, &u.Gender, &u.Languages, &u.AvatarURL, &u.AvatarPublicID, &u.LastActiveAt, &u.CreatedAt, &u.DeletedAt, ) if errors.Is(err, pgx.ErrNoRows) { @@ -134,11 +143,11 @@ func CreateUser(ctx context.Context, pool *pgxpool.Pool, email, username, displa err := pool.QueryRow(ctx, ` INSERT INTO users (email, username, display_name) VALUES ($1, $2, $3) - RETURNING id, username, email, api_key, country, bio, website, - avatar_url, avatar_public_id, created_at, deleted_at + RETURNING id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, + time_zone, gender, languages, avatar_url, avatar_public_id, last_active_at, created_at, deleted_at `, email, username, displayName).Scan( - &u.ID, &u.Username, &u.Email, &u.APIKey, &u.Country, &u.Bio, - &u.Website, &u.AvatarURL, &u.AvatarPublicID, &u.CreatedAt, &u.DeletedAt, + &u.ID, &u.Username, &u.Email, &u.APIKey, &u.DisplayName, &u.FirstName, &u.Role, &u.Location, &u.University, &u.Country, &u.Bio, + &u.Website, &u.TimeZone, &u.Gender, &u.Languages, &u.AvatarURL, &u.AvatarPublicID, &u.LastActiveAt, &u.CreatedAt, &u.DeletedAt, ) if err != nil { diff --git a/apps/api/routes/routes.go b/apps/api/routes/routes.go index e299966..0cda073 100644 --- a/apps/api/routes/routes.go +++ b/apps/api/routes/routes.go @@ -15,6 +15,7 @@ func Setup(app *fiber.App, authHandler *handlers.AuthHandler, heartbeatHandler * auth.Get("/verify", authHandler.VerifyMagicLink) auth.Post("/complete-signup", authHandler.CompleteSignup) auth.Post("/refresh", authHandler.RefreshAccessToken) + auth.Post("/logout", authHandler.Logout) auth.Get("/apikey", middleware.RequireAuth(jwtSecret), authHandler.GetAPIKey) auth.Post("/apikey/regenerate", middleware.RequireAuth(jwtSecret), authHandler.RegenerateAPIKey) auth.Post("/magic-link", middleware.AuthRateLimit(), authHandler.RequestMagicLink) diff --git a/apps/api/services/session_processor.go b/apps/api/services/session_processor.go index bb29401..71e9e0b 100644 --- a/apps/api/services/session_processor.go +++ b/apps/api/services/session_processor.go @@ -6,6 +6,7 @@ import ( "time" "github.com/jackc/pgx/v5/pgxpool" + "github.com/majoramari/seismic/apps/api/models" ) const sessionGap = 2 * time.Minute @@ -65,6 +66,17 @@ func ProcessSessions(ctx context.Context, pool *pgxpool.Pool) error { } } + // Update cached profile stats for affected users + userIDs := make(map[string]struct{}) + for _, h := range heartbeats { + userIDs[h.UserID] = struct{}{} + } + for uid := range userIDs { + if err := models.UpdateProfileStats(ctx, pool, uid); err != nil { + log.Printf("Session processor: failed to update profile stats for user %s: %v\n", uid, err) + } + } + log.Printf("Session processor: created %d sessions from %d heartbeats\n", len(sessions), len(heartbeats)) return nil } From 7b21b2f0e60b348b5a3b9648759695b9097fb052 Mon Sep 17 00:00:00 2001 From: Amr Mousa Date: Tue, 7 Jul 2026 23:37:46 +0300 Subject: [PATCH 3/3] Revert "updates to profile schema, refresh tokens, and stats caching" This reverts commit 670b0eb0e3f81d0355a74dd8d9694a82dfeb9649. --- apps/api/db/migrations/005_extend_users.sql | 10 -- apps/api/db/migrations/006_profile_stats.sql | 10 -- apps/api/db/migrations/007_achievements.sql | 18 --- apps/api/db/migrations/008_activity_log.sql | 15 --- .../009_problem_stats_placeholder.sql | 14 --- apps/api/db/seed_profile.sql | 56 --------- apps/api/docs/swagger.json | 15 ++- apps/api/handlers/auth.go | 34 ------ apps/api/models/profile.go | 109 ------------------ apps/api/models/refresh_token.go | 22 ---- apps/api/models/user.go | 49 ++++---- apps/api/routes/routes.go | 1 - apps/api/services/session_processor.go | 12 -- 13 files changed, 29 insertions(+), 336 deletions(-) delete mode 100644 apps/api/db/migrations/005_extend_users.sql delete mode 100644 apps/api/db/migrations/006_profile_stats.sql delete mode 100644 apps/api/db/migrations/007_achievements.sql delete mode 100644 apps/api/db/migrations/008_activity_log.sql delete mode 100644 apps/api/db/migrations/009_problem_stats_placeholder.sql delete mode 100644 apps/api/db/seed_profile.sql delete mode 100644 apps/api/models/profile.go diff --git a/apps/api/db/migrations/005_extend_users.sql b/apps/api/db/migrations/005_extend_users.sql deleted file mode 100644 index 784a010..0000000 --- a/apps/api/db/migrations/005_extend_users.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Extends the users table with profile-specific fields not yet present -ALTER TABLE users - ADD COLUMN first_name VARCHAR(50), - ADD COLUMN role VARCHAR(50), - ADD COLUMN location VARCHAR(100), - ADD COLUMN university VARCHAR(100), - ADD COLUMN time_zone VARCHAR(50), - ADD COLUMN last_active_at TIMESTAMPTZ, - ADD COLUMN gender VARCHAR(50), - ADD COLUMN languages TEXT[]; -- Simple text array for display purposes on the profile diff --git a/apps/api/db/migrations/006_profile_stats.sql b/apps/api/db/migrations/006_profile_stats.sql deleted file mode 100644 index 96229b6..0000000 --- a/apps/api/db/migrations/006_profile_stats.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Cached aggregate coding data to avoid heavy calculations on every profile view --- This will be populated and updated by the background session-processing job -CREATE TABLE user_profile_stats ( - user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - total_coding_seconds BIGINT DEFAULT 0, - total_active_days INTEGER DEFAULT 0, - current_streak INTEGER DEFAULT 0, - max_streak INTEGER DEFAULT 0, - updated_at TIMESTAMPTZ DEFAULT now() -); diff --git a/apps/api/db/migrations/007_achievements.sql b/apps/api/db/migrations/007_achievements.sql deleted file mode 100644 index 919d055..0000000 --- a/apps/api/db/migrations/007_achievements.sql +++ /dev/null @@ -1,18 +0,0 @@ --- A richer achievement system linking users to specific badges and their metadata -CREATE TABLE achievement_types ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - key VARCHAR(50) UNIQUE NOT NULL, - title VARCHAR(100) NOT NULL, - description TEXT NOT NULL, - badge_class VARCHAR(50) NOT NULL -); - -CREATE TABLE user_achievements ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES users(id) ON DELETE CASCADE, - achievement_type_id UUID REFERENCES achievement_types(id) ON DELETE CASCADE, - earned_at TIMESTAMPTZ DEFAULT now(), - UNIQUE (user_id, achievement_type_id) -); - -CREATE INDEX idx_user_achievements_user_id ON user_achievements(user_id); diff --git a/apps/api/db/migrations/008_activity_log.sql b/apps/api/db/migrations/008_activity_log.sql deleted file mode 100644 index 22747d3..0000000 --- a/apps/api/db/migrations/008_activity_log.sql +++ /dev/null @@ -1,15 +0,0 @@ --- Activity log for the recent activity feed on a user's profile -CREATE TABLE activity_log ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES users(id) ON DELETE CASCADE, - kind VARCHAR(50) NOT NULL, - text TEXT NOT NULL, - metadata JSONB, - created_at TIMESTAMPTZ DEFAULT now() -); - -CREATE INDEX idx_activity_log_user_time ON activity_log(user_id, created_at DESC); - --- Also add index on sessions and heartbeats as requested in the plan -CREATE INDEX idx_sessions_user_time ON sessions(user_id, start_time); -CREATE INDEX idx_heartbeats_user_time ON heartbeats(user_id, time); diff --git a/apps/api/db/migrations/009_problem_stats_placeholder.sql b/apps/api/db/migrations/009_problem_stats_placeholder.sql deleted file mode 100644 index 442a184..0000000 --- a/apps/api/db/migrations/009_problem_stats_placeholder.sql +++ /dev/null @@ -1,14 +0,0 @@ --- Placeholder table for problem-solving stats --- WARNING: There is currently no problem-solving or contest domain in Seismic. --- This schema is intentionally introduced only to satisfy the future Profile API --- and remains unused until such a feature exists. Ratings and contributions are --- strictly placeholders and not derived from any existing system. -CREATE TABLE user_problem_stats ( - user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - solved_count INTEGER DEFAULT 0, - total_problems INTEGER DEFAULT 0, - attempting_count INTEGER DEFAULT 0, - rating INTEGER DEFAULT 0, - contribution_points INTEGER DEFAULT 0, - updated_at TIMESTAMPTZ DEFAULT now() -); diff --git a/apps/api/db/seed_profile.sql b/apps/api/db/seed_profile.sql deleted file mode 100644 index 8a2dd1b..0000000 --- a/apps/api/db/seed_profile.sql +++ /dev/null @@ -1,56 +0,0 @@ --- Ensure we have a user to attach these to -DO $$ -DECLARE - test_user_id UUID; - ach_id UUID; -BEGIN - -- Get or create a test user - SELECT id INTO test_user_id FROM users WHERE username = 'testuser' LIMIT 1; - - IF NOT FOUND THEN - INSERT INTO users (username, email, display_name, first_name, role, location, university, bio, time_zone, gender, languages) - VALUES ('testuser', 'testuser@example.com', 'Test User', 'Test', 'Developer', 'San Francisco, CA', 'Tech University', 'Just a test user building cool things.', 'America/Los_Angeles', 'Male', ARRAY['Go', 'TypeScript', 'SQL']) - RETURNING id INTO test_user_id; - END IF; - - -- Upsert Profile Stats - INSERT INTO user_profile_stats (user_id, total_coding_seconds, total_active_days, current_streak, max_streak) - VALUES (test_user_id, 360000, 45, 12, 15) - ON CONFLICT (user_id) DO UPDATE SET - total_coding_seconds = EXCLUDED.total_coding_seconds, - total_active_days = EXCLUDED.total_active_days, - current_streak = EXCLUDED.current_streak, - max_streak = EXCLUDED.max_streak; - - -- Upsert Problem Stats (Placeholder) - INSERT INTO user_problem_stats (user_id, solved_count, total_problems, attempting_count, rating, contribution_points) - VALUES (test_user_id, 150, 500, 3, 1450, 200) - ON CONFLICT (user_id) DO UPDATE SET - solved_count = EXCLUDED.solved_count, - total_problems = EXCLUDED.total_problems, - attempting_count = EXCLUDED.attempting_count, - rating = EXCLUDED.rating, - contribution_points = EXCLUDED.contribution_points; - - -- Seed Achievement Types - INSERT INTO achievement_types (key, title, description, badge_class) - VALUES - ('first_blood', 'First Blood', 'Logged your first session.', 'gold'), - ('night_owl', 'Night Owl', 'Coded past 2 AM.', 'silver'), - ('streak_7', '7-Day Streak', 'Maintained a 7 day streak.', 'bronze') - ON CONFLICT (key) DO NOTHING; - - -- Give the user an achievement - SELECT id INTO ach_id FROM achievement_types WHERE key = 'first_blood' LIMIT 1; - IF ach_id IS NOT NULL THEN - INSERT INTO user_achievements (user_id, achievement_type_id) - VALUES (test_user_id, ach_id) - ON CONFLICT (user_id, achievement_type_id) DO NOTHING; - END IF; - - -- Seed Activity Log - INSERT INTO activity_log (user_id, kind, text) - VALUES (test_user_id, 'achievement', 'Earned the First Blood badge!'), - (test_user_id, 'streak', 'Reached a 12 day streak!'); - -END $$; diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index 88a3fea..b2f5bc0 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -548,7 +548,8 @@ } } }, - +<<<<<<< HEAD +======= "/api/profile": { "get": { "security": [ @@ -580,7 +581,7 @@ } } }, - +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "/api/settings/account": { "post": { "security": [ @@ -848,7 +849,8 @@ } } }, - +<<<<<<< HEAD +======= "models.Achievement": { "type": "object", "properties": { @@ -883,7 +885,7 @@ } } }, - +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "models.Heartbeat": { "type": "object", "properties": { @@ -921,7 +923,8 @@ "type": "string" } } - +<<<<<<< HEAD +======= }, "models.HeatmapCell": { "type": "object", @@ -1059,7 +1062,7 @@ "type": "string" } } - +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) } }, "securityDefinitions": { diff --git a/apps/api/handlers/auth.go b/apps/api/handlers/auth.go index 42c0484..b1f96f9 100644 --- a/apps/api/handlers/auth.go +++ b/apps/api/handlers/auth.go @@ -237,45 +237,11 @@ func (h *AuthHandler) RefreshAccessToken(c *fiber.Ctx) error { return helpers.Error(c, fiber.StatusInternalServerError, "Something went wrong") } - // Clean up expired tokens periodically (non-blocking) - _, _ = models.CleanupExpiredRefreshTokens(ctx, h.Pool) - return helpers.Success(c, "Token refreshed", fiber.Map{ "accessToken": accessToken, }) } -// Logout godoc -// @Summary Log out current user -// @Description Revokes the refresh token cookie and clears it from the browser. -// @Tags auth -// @Produce json -// @Success 200 {object} helpers.APIResponse -// @Router /api/auth/logout [post] -func (h *AuthHandler) Logout(c *fiber.Ctx) error { - rawToken := c.Cookies("refresh_token") - if rawToken != "" { - ctx := c.Context() - stored, err := models.FindValidRefreshToken(ctx, h.Pool, rawToken) - if err == nil && stored != nil { - _ = models.RevokeRefreshToken(ctx, h.Pool, stored.ID) - } - } - - // Clear the cookie - c.Cookie(&fiber.Cookie{ - Name: "refresh_token", - Value: "", - HTTPOnly: true, - Secure: strings.HasPrefix(h.EmailCfg.AppURL, "https"), - SameSite: "None", - Path: "/api/auth", - Expires: time.Now().Add(-1 * time.Hour), - }) - - return helpers.Success(c, "Logged out", nil) -} - // setRefreshTokenCookie sets the refresh token as an httpOnly // cookie so client-side JavaScript can never read it directly. // Secure is disabled for local http testing, enabled in diff --git a/apps/api/models/profile.go b/apps/api/models/profile.go deleted file mode 100644 index b7e5d82..0000000 --- a/apps/api/models/profile.go +++ /dev/null @@ -1,109 +0,0 @@ -package models - -import ( - "context" - "time" - - "github.com/jackc/pgx/v5/pgxpool" -) - -// UserProfileStats caches aggregate coding data for the profile page -type UserProfileStats struct { - UserID string `json:"userId"` - TotalCodingSeconds int64 `json:"totalCodingSeconds"` - TotalActiveDays int `json:"totalActiveDays"` - CurrentStreak int `json:"currentStreak"` - MaxStreak int `json:"maxStreak"` - UpdatedAt time.Time `json:"updatedAt"` -} - -// GetProfileStats retrieves cached profile stats for a user. -func GetProfileStats(ctx context.Context, pool *pgxpool.Pool, userID string) (*UserProfileStats, error) { - var s UserProfileStats - err := pool.QueryRow(ctx, ` - SELECT user_id, total_coding_seconds, total_active_days, current_streak, max_streak, updated_at - FROM user_profile_stats - WHERE user_id = $1 - `, userID).Scan(&s.UserID, &s.TotalCodingSeconds, &s.TotalActiveDays, &s.CurrentStreak, &s.MaxStreak, &s.UpdatedAt) - if err != nil { - return nil, err - } - return &s, nil -} - -// UpdateProfileStats recalculates aggregate stats from the sessions table -// and upserts them into user_profile_stats for the given user. -func UpdateProfileStats(ctx context.Context, pool *pgxpool.Pool, userID string) error { - var totalSeconds int64 - _ = pool.QueryRow(ctx, ` - SELECT COALESCE(SUM(duration_seconds), 0) FROM sessions WHERE user_id = $1 - `, userID).Scan(&totalSeconds) - - var totalDays int - _ = pool.QueryRow(ctx, ` - SELECT COUNT(DISTINCT start_time::date) FROM sessions WHERE user_id = $1 - `, userID).Scan(&totalDays) - - currentStreak, _ := GetCurrentStreak(ctx, pool, userID) - - var prevMax int - _ = pool.QueryRow(ctx, ` - SELECT COALESCE(max_streak, 0) FROM user_profile_stats WHERE user_id = $1 - `, userID).Scan(&prevMax) - - maxStreak := prevMax - if currentStreak > maxStreak { - maxStreak = currentStreak - } - - _, err := pool.Exec(ctx, ` - INSERT INTO user_profile_stats (user_id, total_coding_seconds, total_active_days, current_streak, max_streak, updated_at) - VALUES ($1, $2, $3, $4, $5, NOW()) - ON CONFLICT (user_id) DO UPDATE SET - total_coding_seconds = EXCLUDED.total_coding_seconds, - total_active_days = EXCLUDED.total_active_days, - current_streak = EXCLUDED.current_streak, - max_streak = GREATEST(user_profile_stats.max_streak, EXCLUDED.current_streak), - updated_at = EXCLUDED.updated_at - `, userID, totalSeconds, totalDays, currentStreak, maxStreak) - - return err -} - -// AchievementType represents a badge that can be earned -type AchievementType struct { - ID string `json:"id"` - Key string `json:"key"` - Title string `json:"title"` - Description string `json:"description"` - BadgeClass string `json:"badgeClass"` -} - -// UserAchievement tracks which user earned which badge and when -type UserAchievement struct { - ID string `json:"id"` - UserID string `json:"userId"` - AchievementTypeID string `json:"achievementTypeId"` - EarnedAt time.Time `json:"earnedAt"` -} - -// ActivityLog represents an entry in the user's recent activity feed -type ActivityLog struct { - ID string `json:"id"` - UserID string `json:"userId"` - Kind string `json:"kind"` - Text string `json:"text"` - Metadata any `json:"metadata,omitempty"` - CreatedAt time.Time `json:"createdAt"` -} - -// UserProblemStats is a placeholder for future problem-solving features -type UserProblemStats struct { - UserID string `json:"userId"` - SolvedCount int `json:"solvedCount"` - TotalProblems int `json:"totalProblems"` - AttemptingCount int `json:"attemptingCount"` - Rating int `json:"rating"` - ContributionPoints int `json:"contributionPoints"` - UpdatedAt time.Time `json:"updatedAt"` -} diff --git a/apps/api/models/refresh_token.go b/apps/api/models/refresh_token.go index 3c5e6e4..169d2ea 100644 --- a/apps/api/models/refresh_token.go +++ b/apps/api/models/refresh_token.go @@ -95,25 +95,3 @@ func RevokeRefreshToken(ctx context.Context, pool *pgxpool.Pool, id string) erro `, id) return err } - -// RevokeAllUserRefreshTokens revokes every non-revoked token for a user. -// Useful when rotating all tokens (e.g. password change, account compromise). -func RevokeAllUserRefreshTokens(ctx context.Context, pool *pgxpool.Pool, userID string) error { - _, err := pool.Exec(ctx, ` - UPDATE refresh_tokens SET revoked = true - WHERE user_id = $1 AND revoked = false - `, userID) - return err -} - -// CleanupExpiredRefreshTokens deletes all expired tokens from the database. -// Should be called periodically to prevent the table from growing unbounded. -func CleanupExpiredRefreshTokens(ctx context.Context, pool *pgxpool.Pool) (int64, error) { - res, err := pool.Exec(ctx, ` - DELETE FROM refresh_tokens WHERE expires_at < NOW() - `) - if err != nil { - return 0, err - } - return res.RowsAffected(), nil -} diff --git a/apps/api/models/user.go b/apps/api/models/user.go index f26fcf3..7059d25 100644 --- a/apps/api/models/user.go +++ b/apps/api/models/user.go @@ -15,20 +15,11 @@ type User struct { Username string `json:"username"` Email string `json:"email"` APIKey string `json:"apiKey"` - DisplayName *string `json:"displayName"` - FirstName *string `json:"firstName"` - Role *string `json:"role"` - Location *string `json:"location"` - University *string `json:"university"` Country *string `json:"country"` Bio *string `json:"bio"` Website *string `json:"website"` - TimeZone *string `json:"timeZone"` - Gender *string `json:"gender"` - Languages []string `json:"languages"` AvatarURL *string `json:"avatarUrl"` AvatarPublicID *string `json:"-"` - LastActiveAt *time.Time `json:"lastActiveAt"` CreatedAt time.Time `json:"createdAt"` DeletedAt *time.Time `json:"-"` } @@ -41,13 +32,13 @@ func FindUserByEmail(ctx context.Context, pool *pgxpool.Pool, email string) (*Us var u User err := pool.QueryRow(ctx, ` - SELECT id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, - time_zone, gender, languages, avatar_url, avatar_public_id, last_active_at, created_at, deleted_at + SELECT id, username, email, api_key, country, bio, website, + avatar_url, avatar_public_id, created_at, deleted_at FROM users WHERE email = $1 AND deleted_at IS NULL `, email).Scan( - &u.ID, &u.Username, &u.Email, &u.APIKey, &u.DisplayName, &u.FirstName, &u.Role, &u.Location, &u.University, &u.Country, &u.Bio, - &u.Website, &u.TimeZone, &u.Gender, &u.Languages, &u.AvatarURL, &u.AvatarPublicID, &u.LastActiveAt, &u.CreatedAt, &u.DeletedAt, + &u.ID, &u.Username, &u.Email, &u.APIKey, &u.Country, &u.Bio, + &u.Website, &u.AvatarURL, &u.AvatarPublicID, &u.CreatedAt, &u.DeletedAt, ) if errors.Is(err, pgx.ErrNoRows) { @@ -67,13 +58,13 @@ func FindUserByUsername(ctx context.Context, pool *pgxpool.Pool, username string var u User err := pool.QueryRow(ctx, ` - SELECT id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, - time_zone, gender, languages, avatar_url, avatar_public_id, last_active_at, created_at, deleted_at + SELECT id, username, email, api_key, country, bio, website, + avatar_url, avatar_public_id, created_at, deleted_at FROM users WHERE username = $1 AND deleted_at IS NULL `, username).Scan( - &u.ID, &u.Username, &u.Email, &u.APIKey, &u.DisplayName, &u.FirstName, &u.Role, &u.Location, &u.University, &u.Country, &u.Bio, - &u.Website, &u.TimeZone, &u.Gender, &u.Languages, &u.AvatarURL, &u.AvatarPublicID, &u.LastActiveAt, &u.CreatedAt, &u.DeletedAt, + &u.ID, &u.Username, &u.Email, &u.APIKey, &u.Country, &u.Bio, + &u.Website, &u.AvatarURL, &u.AvatarPublicID, &u.CreatedAt, &u.DeletedAt, ) if errors.Is(err, pgx.ErrNoRows) { @@ -91,13 +82,13 @@ func FindUserByID(ctx context.Context, pool *pgxpool.Pool, id string) (*User, er var u User err := pool.QueryRow(ctx, ` - SELECT id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, - time_zone, gender, languages, avatar_url, avatar_public_id, last_active_at, created_at, deleted_at + SELECT id, username, email, api_key, country, bio, website, + avatar_url, avatar_public_id, created_at, deleted_at FROM users WHERE id = $1 AND deleted_at IS NULL `, id).Scan( - &u.ID, &u.Username, &u.Email, &u.APIKey, &u.DisplayName, &u.FirstName, &u.Role, &u.Location, &u.University, &u.Country, &u.Bio, - &u.Website, &u.TimeZone, &u.Gender, &u.Languages, &u.AvatarURL, &u.AvatarPublicID, &u.LastActiveAt, &u.CreatedAt, &u.DeletedAt, + &u.ID, &u.Username, &u.Email, &u.APIKey, &u.Country, &u.Bio, + &u.Website, &u.AvatarURL, &u.AvatarPublicID, &u.CreatedAt, &u.DeletedAt, ) if errors.Is(err, pgx.ErrNoRows) { @@ -116,13 +107,13 @@ func FindUserByAPIKey(ctx context.Context, pool *pgxpool.Pool, apiKey string) (* var u User err := pool.QueryRow(ctx, ` - SELECT id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, - time_zone, gender, languages, avatar_url, avatar_public_id, last_active_at, created_at, deleted_at + SELECT id, username, email, api_key, country, bio, website, + avatar_url, avatar_public_id, created_at, deleted_at FROM users WHERE api_key = $1 AND deleted_at IS NULL `, apiKey).Scan( - &u.ID, &u.Username, &u.Email, &u.APIKey, &u.DisplayName, &u.FirstName, &u.Role, &u.Location, &u.University, &u.Country, &u.Bio, - &u.Website, &u.TimeZone, &u.Gender, &u.Languages, &u.AvatarURL, &u.AvatarPublicID, &u.LastActiveAt, &u.CreatedAt, &u.DeletedAt, + &u.ID, &u.Username, &u.Email, &u.APIKey, &u.Country, &u.Bio, + &u.Website, &u.AvatarURL, &u.AvatarPublicID, &u.CreatedAt, &u.DeletedAt, ) if errors.Is(err, pgx.ErrNoRows) { @@ -143,11 +134,11 @@ func CreateUser(ctx context.Context, pool *pgxpool.Pool, email, username, displa err := pool.QueryRow(ctx, ` INSERT INTO users (email, username, display_name) VALUES ($1, $2, $3) - RETURNING id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, - time_zone, gender, languages, avatar_url, avatar_public_id, last_active_at, created_at, deleted_at + RETURNING id, username, email, api_key, country, bio, website, + avatar_url, avatar_public_id, created_at, deleted_at `, email, username, displayName).Scan( - &u.ID, &u.Username, &u.Email, &u.APIKey, &u.DisplayName, &u.FirstName, &u.Role, &u.Location, &u.University, &u.Country, &u.Bio, - &u.Website, &u.TimeZone, &u.Gender, &u.Languages, &u.AvatarURL, &u.AvatarPublicID, &u.LastActiveAt, &u.CreatedAt, &u.DeletedAt, + &u.ID, &u.Username, &u.Email, &u.APIKey, &u.Country, &u.Bio, + &u.Website, &u.AvatarURL, &u.AvatarPublicID, &u.CreatedAt, &u.DeletedAt, ) if err != nil { diff --git a/apps/api/routes/routes.go b/apps/api/routes/routes.go index 0cda073..e299966 100644 --- a/apps/api/routes/routes.go +++ b/apps/api/routes/routes.go @@ -15,7 +15,6 @@ func Setup(app *fiber.App, authHandler *handlers.AuthHandler, heartbeatHandler * auth.Get("/verify", authHandler.VerifyMagicLink) auth.Post("/complete-signup", authHandler.CompleteSignup) auth.Post("/refresh", authHandler.RefreshAccessToken) - auth.Post("/logout", authHandler.Logout) auth.Get("/apikey", middleware.RequireAuth(jwtSecret), authHandler.GetAPIKey) auth.Post("/apikey/regenerate", middleware.RequireAuth(jwtSecret), authHandler.RegenerateAPIKey) auth.Post("/magic-link", middleware.AuthRateLimit(), authHandler.RequestMagicLink) diff --git a/apps/api/services/session_processor.go b/apps/api/services/session_processor.go index 71e9e0b..bb29401 100644 --- a/apps/api/services/session_processor.go +++ b/apps/api/services/session_processor.go @@ -6,7 +6,6 @@ import ( "time" "github.com/jackc/pgx/v5/pgxpool" - "github.com/majoramari/seismic/apps/api/models" ) const sessionGap = 2 * time.Minute @@ -66,17 +65,6 @@ func ProcessSessions(ctx context.Context, pool *pgxpool.Pool) error { } } - // Update cached profile stats for affected users - userIDs := make(map[string]struct{}) - for _, h := range heartbeats { - userIDs[h.UserID] = struct{}{} - } - for uid := range userIDs { - if err := models.UpdateProfileStats(ctx, pool, uid); err != nil { - log.Printf("Session processor: failed to update profile stats for user %s: %v\n", uid, err) - } - } - log.Printf("Session processor: created %d sessions from %d heartbeats\n", len(sessions), len(heartbeats)) return nil }