diff --git a/apps/backend/src/db/migrations/0003_wide_inertia.sql b/apps/backend/src/db/migrations/0003_wide_inertia.sql new file mode 100644 index 0000000..9fdfc52 --- /dev/null +++ b/apps/backend/src/db/migrations/0003_wide_inertia.sql @@ -0,0 +1 @@ +ALTER TABLE "session" ADD COLUMN "last_used_at" timestamp with time zone DEFAULT now() NOT NULL; \ No newline at end of file diff --git a/apps/backend/src/db/migrations/meta/0003_snapshot.json b/apps/backend/src/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..3b9318b --- /dev/null +++ b/apps/backend/src/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,724 @@ +{ + "id": "76f6c239-e114-4f0b-a9a4-560145edcb9c", + "prevId": "b16ad200-4fb1-4cee-977e-7d7fac349d7b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.fcm_token": { + "name": "fcm_token", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "fcm_token_session_idx": { + "name": "fcm_token_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fcm_token_user_id_user_id_fk": { + "name": "fcm_token_user_id_user_id_fk", + "tableFrom": "fcm_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "fcm_token_session_id_session_id_fk": { + "name": "fcm_token_session_id_session_id_fk", + "tableFrom": "fcm_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fcm_token_user_id_device_id_pk": { + "name": "fcm_token_user_id_device_id_pk", + "columns": [ + "user_id", + "device_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.note": { + "name": "note", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "note_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "is_complete": { + "name": "is_complete", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "NULL" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_idx": { + "name": "user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "note_user_id_user_id_fk": { + "name": "note_user_id_user_id_fk", + "tableFrom": "note", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.notes_to_users": { + "name": "notes_to_users", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "note_id": { + "name": "note_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "notes_to_users_user_id_user_id_fk": { + "name": "notes_to_users_user_id_user_id_fk", + "tableFrom": "notes_to_users", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notes_to_users_note_id_note_id_fk": { + "name": "notes_to_users_note_id_note_id_fk", + "tableFrom": "notes_to_users", + "tableTo": "note", + "columnsFrom": [ + "note_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "notes_to_users_user_id_note_id_pk": { + "name": "notes_to_users_user_id_note_id_pk", + "columns": [ + "user_id", + "note_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "device_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'UNKNOWN'" + }, + "os": { + "name": "os", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "os_version": { + "name": "os_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_user_id_user_id_fk": { + "name": "device_user_id_user_id_fk", + "tableFrom": "device", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "device_session_id_session_id_fk": { + "name": "device_session_id_session_id_fk", + "tableFrom": "device", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "device_user_id_session_id_pk": { + "name": "device_user_id_session_id_pk", + "columns": [ + "user_id", + "session_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.email_verification_code": { + "name": "email_verification_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "emai": { + "name": "emai", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "email_verification_code_user_idx": { + "name": "email_verification_code_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_verification_code_user_id_unique": { + "name": "email_verification_code_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + } + }, + "public.oauth_account": { + "name": "oauth_account", + "schema": "", + "columns": { + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_account_user_id_user_id_fk": { + "name": "oauth_account_user_id_user_id_fk", + "tableFrom": "oauth_account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "oauth_account_provider_id_provider_user_id_pk": { + "name": "oauth_account_provider_id_provider_user_id_pk", + "columns": [ + "provider_id", + "provider_user_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.password_reset_token": { + "name": "password_reset_token", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "password_reset_token_user_idx": { + "name": "password_reset_token_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "password_reset_token_user_id_user_id_fk": { + "name": "password_reset_token_user_id_user_id_fk", + "tableFrom": "password_reset_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hashed_password": { + "name": "hashed_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_sign_in_at": { + "name": "last_sign_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_idx": { + "name": "email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + } + }, + "enums": { + "public.note_category": { + "name": "note_category", + "schema": "public", + "values": [ + "general", + "important" + ] + }, + "public.device_type": { + "name": "device_type", + "schema": "public", + "values": [ + "UNKNOWN", + "PHONE", + "TABLET", + "DESKTOP", + "TV" + ] + } + }, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/backend/src/db/migrations/meta/_journal.json b/apps/backend/src/db/migrations/meta/_journal.json index 7c211b1..1a79a58 100644 --- a/apps/backend/src/db/migrations/meta/_journal.json +++ b/apps/backend/src/db/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1722960820070, "tag": "0002_glossy_vampiro", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1723334062399, + "tag": "0003_wide_inertia", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/backend/src/db/schema/user.ts b/apps/backend/src/db/schema/user.ts index d9808f8..b0591b5 100644 --- a/apps/backend/src/db/schema/user.ts +++ b/apps/backend/src/db/schema/user.ts @@ -65,13 +65,22 @@ export type UserTable = InferSelectModel; export const sessionTable = pgTable("session", { id: text("id").notNull().primaryKey(), + userId: integer("user_id") .notNull() .references(() => userTable.id, { onDelete: "cascade" }), + expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date" - }).notNull() + }).notNull(), + + lastUsedAt: timestamp("last_used_at", { + withTimezone: true, + mode: "string" + }) + .notNull() + .defaultNow() }); export const emailVerificationCodeTable = pgTable( diff --git a/apps/backend/src/libs/background-worker.ts b/apps/backend/src/libs/background-worker.ts index 4a29cb2..67e2742 100644 --- a/apps/backend/src/libs/background-worker.ts +++ b/apps/backend/src/libs/background-worker.ts @@ -5,6 +5,7 @@ import { env } from "@/env"; import { saveDevice } from "./background/save-device"; import { sendVerificationCode } from "./background/send-verification-code"; +import { updateSessionLastUsedAt } from "./background/update-session-last-used"; const connection = new IORedis(env.REDIS_URL, { enableOfflineQueue: false, @@ -36,6 +37,10 @@ const BgWorker = new Worker( await sendVerificationCode(job.data); break; + case "updateSessionLastUsedAt": + await updateSessionLastUsedAt(job.data); + break; + default: console.error(`Unknown job type: ${job.name}`); } diff --git a/apps/backend/src/libs/background/update-session-last-used.ts b/apps/backend/src/libs/background/update-session-last-used.ts new file mode 100644 index 0000000..be43d3b --- /dev/null +++ b/apps/backend/src/libs/background/update-session-last-used.ts @@ -0,0 +1,19 @@ +import { eq } from "drizzle-orm"; + +import { db } from "@/db"; +import { sessionTable } from "@/db/schema/user"; + +export type UpdateSessionLastUsedAtProps = { + sessionId: string; + lastUsedAt: string; +}; + +export async function updateSessionLastUsedAt({ + sessionId, + lastUsedAt +}: UpdateSessionLastUsedAtProps) { + await db + .update(sessionTable) + .set({ lastUsedAt }) + .where(eq(sessionTable.id, sessionId)); +} diff --git a/apps/backend/src/v1/controllers/user/get-devices.ts b/apps/backend/src/v1/controllers/user/get-devices.ts index 28f9b77..a894b48 100644 --- a/apps/backend/src/v1/controllers/user/get-devices.ts +++ b/apps/backend/src/v1/controllers/user/get-devices.ts @@ -1,4 +1,4 @@ -import { and, eq, getTableColumns, ne } from "drizzle-orm"; +import { and, desc, eq, getTableColumns, ne } from "drizzle-orm"; import { Context } from "elysia"; import { db } from "@/db"; @@ -34,6 +34,7 @@ export async function getDevices({ user, error }: GetDevicesProps) { ne(deviceTable.sessionId, user.session.id) ) ) + .orderBy(desc(deviceTable.createdAt)) ]); return { current, others: others as Device[] }; diff --git a/apps/backend/src/v1/controllers/user/profile.ts b/apps/backend/src/v1/controllers/user/profile.ts index 4175e89..b119569 100644 --- a/apps/backend/src/v1/controllers/user/profile.ts +++ b/apps/backend/src/v1/controllers/user/profile.ts @@ -1,6 +1,8 @@ import { Context } from "elysia"; import { lucia } from "@/libs/auth"; +import { BgQueue } from "@/libs/background-worker"; +import { UpdateSessionLastUsedAtProps } from "@/libs/background/update-session-last-used"; import { VerifyJwtAsync } from "@/libs/jwt"; import { UserResponse } from "@/v1/validations/user"; @@ -27,6 +29,11 @@ export async function getProfile({ bearer, error }: GetProfileProps) { return error(403, "Your account has been disabled"); } + await BgQueue.add("updateSessionLastUsedAt", { + sessionId: session.id, + lastUsedAt: new Date().toISOString() + } satisfies UpdateSessionLastUsedAtProps).catch(() => {}); + return { id: user.id, email: user.email, diff --git a/apps/backend/src/v1/utils/note/derive-user.ts b/apps/backend/src/v1/utils/note/derive-user.ts index 2f87d95..43504fb 100644 --- a/apps/backend/src/v1/utils/note/derive-user.ts +++ b/apps/backend/src/v1/utils/note/derive-user.ts @@ -17,6 +17,7 @@ export async function deriveUser({ if (typeof sessionId !== "string") return error(401, "Unauthorized"); const { user, session } = await lucia.validateSession(sessionId); + if (!user) return error(401, "Unauthorized"); if (user.disabled) {