From f35f4565b649aadbf82e7868d7c5a0d78322ad21 Mon Sep 17 00:00:00 2001 From: whidix Date: Fri, 6 Mar 2026 19:28:42 +0100 Subject: [PATCH] feat: implement friendship system with requests and management features - Added a new friendship table schema to manage friend requests and relationships. - Updated profile page to include tabs for managing friends, incoming requests, and outgoing requests. - Implemented functionality to send, accept, decline, cancel, and remove friend requests. - Enhanced user experience with feedback messages for friend request actions. --- drizzle/0008_skinny_warpath.sql | 12 + drizzle/meta/0008_snapshot.json | 1262 +++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/lib/server/db/schema.ts | 19 + src/routes/(game)/profile/+page.server.ts | 269 ++++- src/routes/(game)/profile/+page.svelte | 150 ++- 6 files changed, 1714 insertions(+), 5 deletions(-) create mode 100644 drizzle/0008_skinny_warpath.sql create mode 100644 drizzle/meta/0008_snapshot.json diff --git a/drizzle/0008_skinny_warpath.sql b/drizzle/0008_skinny_warpath.sql new file mode 100644 index 0000000..0e898fb --- /dev/null +++ b/drizzle/0008_skinny_warpath.sql @@ -0,0 +1,12 @@ +CREATE TABLE `friendship` ( + `id` text PRIMARY KEY NOT NULL, + `requesterId` text NOT NULL, + `addresseeId` text NOT NULL, + `status` text DEFAULT 'pending' NOT NULL, + `createdAt` integer NOT NULL, + `updatedAt` integer NOT NULL, + FOREIGN KEY (`requesterId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`addresseeId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `friendship_requesterId_addresseeId_unique` ON `friendship` (`requesterId`,`addresseeId`); \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..af8b7ae --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,1262 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "4a7e5309-2dbd-4e06-a760-5c06089f899e", + "prevId": "bcd05dfd-c031-4939-8060-ab247fd82923", + "tables": { + "arc": { + "name": "arc", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "startChapter": { + "name": "startChapter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endChapter": { + "name": "endChapter", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "character": { + "name": "character", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "affiliations": { + "name": "affiliations", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "devilFruitId": { + "name": "devilFruitId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hakiObservation": { + "name": "hakiObservation", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "hakiArmament": { + "name": "hakiArmament", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "hakiConqueror": { + "name": "hakiConqueror", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "bounty": { + "name": "bounty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "height": { + "name": "height", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "firstAppearance": { + "name": "firstAppearance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pictureUrl": { + "name": "pictureUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "epithets": { + "name": "epithets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "arcId": { + "name": "arcId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "isInDailyMode": { + "name": "isInDailyMode", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "character_devilFruitId_devilFruit_id_fk": { + "name": "character_devilFruitId_devilFruit_id_fk", + "tableFrom": "character", + "tableTo": "devilFruit", + "columnsFrom": [ + "devilFruitId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "character_arcId_arc_id_fk": { + "name": "character_arcId_arc_id_fk", + "tableFrom": "character", + "tableTo": "arc", + "columnsFrom": [ + "arcId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "characterHistory": { + "name": "characterHistory", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "characterId": { + "name": "characterId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "won": { + "name": "won", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "characterHistory_date_unique": { + "name": "characterHistory_date_unique", + "columns": [ + "date" + ], + "isUnique": true + } + }, + "foreignKeys": { + "characterHistory_characterId_character_id_fk": { + "name": "characterHistory_characterId_character_id_fk", + "tableFrom": "characterHistory", + "tableTo": "character", + "columnsFrom": [ + "characterId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "characterOverride": { + "name": "characterOverride", + "columns": { + "characterId": { + "name": "characterId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "affiliations": { + "name": "affiliations", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "devilFruitId": { + "name": "devilFruitId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hakiObservation": { + "name": "hakiObservation", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hakiArmament": { + "name": "hakiArmament", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hakiConqueror": { + "name": "hakiConqueror", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bounty": { + "name": "bounty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "firstAppearance": { + "name": "firstAppearance", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pictureUrl": { + "name": "pictureUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "epithets": { + "name": "epithets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "arcId": { + "name": "arcId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "characterOverride_characterId_character_id_fk": { + "name": "characterOverride_characterId_character_id_fk", + "tableFrom": "characterOverride", + "tableTo": "character", + "columnsFrom": [ + "characterId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "characterOverride_devilFruitId_devilFruit_id_fk": { + "name": "characterOverride_devilFruitId_devilFruit_id_fk", + "tableFrom": "characterOverride", + "tableTo": "devilFruit", + "columnsFrom": [ + "devilFruitId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "characterOverride_arcId_arc_id_fk": { + "name": "characterOverride_arcId_arc_id_fk", + "tableFrom": "characterOverride", + "tableTo": "arc", + "columnsFrom": [ + "arcId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "characterScrapeValidation": { + "name": "characterScrapeValidation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "affiliations": { + "name": "affiliations", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "devilFruitId": { + "name": "devilFruitId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hakiObservation": { + "name": "hakiObservation", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "hakiArmament": { + "name": "hakiArmament", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "hakiConqueror": { + "name": "hakiConqueror", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "bounty": { + "name": "bounty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "firstAppearance": { + "name": "firstAppearance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pictureUrl": { + "name": "pictureUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "epithets": { + "name": "epithets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "arcId": { + "name": "arcId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "characterScrapeValidation_devilFruitId_devilFruit_id_fk": { + "name": "characterScrapeValidation_devilFruitId_devilFruit_id_fk", + "tableFrom": "characterScrapeValidation", + "tableTo": "devilFruit", + "columnsFrom": [ + "devilFruitId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "characterScrapeValidation_arcId_arc_id_fk": { + "name": "characterScrapeValidation_arcId_arc_id_fk", + "tableFrom": "characterScrapeValidation", + "tableTo": "arc", + "columnsFrom": [ + "arcId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "config": { + "name": "config", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "devilFruit": { + "name": "devilFruit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "devilFruit_name_unique": { + "name": "devilFruit_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "friendship": { + "name": "friendship", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "requesterId": { + "name": "requesterId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addresseeId": { + "name": "addresseeId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "friendship_requesterId_addresseeId_unique": { + "name": "friendship_requesterId_addresseeId_unique", + "columns": [ + "requesterId", + "addresseeId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "friendship_requesterId_user_id_fk": { + "name": "friendship_requesterId_user_id_fk", + "tableFrom": "friendship", + "tableTo": "user", + "columnsFrom": [ + "requesterId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "friendship_addresseeId_user_id_fk": { + "name": "friendship_addresseeId_user_id_fk", + "tableFrom": "friendship", + "tableTo": "user", + "columnsFrom": [ + "addresseeId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "userCharacterHistory": { + "name": "userCharacterHistory", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "characterHistoryId": { + "name": "characterHistoryId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tryCount": { + "name": "tryCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "userCharacterHistory_userId_characterHistoryId_unique": { + "name": "userCharacterHistory_userId_characterHistoryId_unique", + "columns": [ + "userId", + "characterHistoryId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "userCharacterHistory_userId_user_id_fk": { + "name": "userCharacterHistory_userId_user_id_fk", + "tableFrom": "userCharacterHistory", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "userCharacterHistory_characterHistoryId_characterHistory_id_fk": { + "name": "userCharacterHistory_characterHistoryId_characterHistory_id_fk", + "tableFrom": "userCharacterHistory", + "tableTo": "characterHistory", + "columnsFrom": [ + "characterHistoryId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "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": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index cd5a337..b65d8b1 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1772735982970, "tag": "0007_gray_shinko_yamashiro", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1772821532270, + "tag": "0008_skinny_warpath", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index e5cb44e..0cff2bf 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -5,6 +5,7 @@ import { user } from './auth.schema'; export type DevilFruitType = 'Paramecia' | 'Zoan' | 'Logia' | 'Smile' | 'Unknown'; export type Status = 'Alive' | 'Dead' | 'Unknown'; +export type FriendshipStatus = 'pending' | 'accepted' | 'declined'; // Define the site config table schema export const config = sqliteTable('config', { @@ -122,5 +123,23 @@ export const userCharacterHistory = sqliteTable('userCharacterHistory', { unique().on(table.userId, table.characterHistoryId) ]); +// Define the friendship table schema (friend requests + accepted friends) +export const friendship = sqliteTable('friendship', { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + requesterId: text('requesterId') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + addresseeId: text('addresseeId') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + status: text('status').$type().notNull().default('pending'), + createdAt: integer('createdAt').notNull().$default(() => Date.now()), + updatedAt: integer('updatedAt').notNull().$default(() => Date.now()), +}, (table) => [ + unique().on(table.requesterId, table.addresseeId) +]); + export * from './auth.schema'; diff --git a/src/routes/(game)/profile/+page.server.ts b/src/routes/(game)/profile/+page.server.ts index 83a0114..75c2c74 100644 --- a/src/routes/(game)/profile/+page.server.ts +++ b/src/routes/(game)/profile/+page.server.ts @@ -2,8 +2,8 @@ import { fail, redirect } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; import { auth } from '$lib/server/auth'; import { db } from '$lib/server/db'; -import { session, userCharacterHistory, characterHistory, character } from '$lib/server/db/schema'; -import { eq, desc } from 'drizzle-orm'; +import { session, userCharacterHistory, characterHistory, character, friendship, user } from '$lib/server/db/schema'; +import { and, desc, eq, or } from 'drizzle-orm'; import { APIError } from 'better-auth/api'; export const load: PageServerLoad = async (event) => { @@ -11,6 +11,8 @@ export const load: PageServerLoad = async (event) => { return redirect(302, '/login'); } + const currentUserId = event.locals.user.id; + // Fetch all sessions for this user const userSessions = await db .select() @@ -34,10 +36,69 @@ export const load: PageServerLoad = async (event) => { .where(eq(userCharacterHistory.userId, event.locals.user.id)) .orderBy(desc(characterHistory.date)); + const incomingRequests = await db + .select({ + id: friendship.id, + createdAt: friendship.createdAt, + requesterId: friendship.requesterId, + requesterName: user.name, + requesterEmail: user.email, + requesterImage: user.image + }) + .from(friendship) + .innerJoin(user, eq(friendship.requesterId, user.id)) + .where(and(eq(friendship.addresseeId, currentUserId), eq(friendship.status, 'pending'))) + .orderBy(desc(friendship.createdAt)); + + const outgoingRequests = await db + .select({ + id: friendship.id, + createdAt: friendship.createdAt, + addresseeId: friendship.addresseeId, + addresseeName: user.name, + addresseeEmail: user.email, + addresseeImage: user.image + }) + .from(friendship) + .innerJoin(user, eq(friendship.addresseeId, user.id)) + .where(and(eq(friendship.requesterId, currentUserId), eq(friendship.status, 'pending'))) + .orderBy(desc(friendship.createdAt)); + + const acceptedAsRequester = await db + .select({ + id: friendship.id, + createdAt: friendship.createdAt, + friendId: friendship.addresseeId, + friendName: user.name, + friendEmail: user.email, + friendImage: user.image + }) + .from(friendship) + .innerJoin(user, eq(friendship.addresseeId, user.id)) + .where(and(eq(friendship.requesterId, currentUserId), eq(friendship.status, 'accepted'))); + + const acceptedAsAddressee = await db + .select({ + id: friendship.id, + createdAt: friendship.createdAt, + friendId: friendship.requesterId, + friendName: user.name, + friendEmail: user.email, + friendImage: user.image + }) + .from(friendship) + .innerJoin(user, eq(friendship.requesterId, user.id)) + .where(and(eq(friendship.addresseeId, currentUserId), eq(friendship.status, 'accepted'))); + + const friends = [...acceptedAsRequester, ...acceptedAsAddressee].sort((a, b) => b.createdAt - a.createdAt); + return { user: event.locals.user, sessions: userSessions, - dailyHistory: dailyHistory + dailyHistory: dailyHistory, + incomingRequests, + outgoingRequests, + friends }; }; @@ -133,5 +194,207 @@ export const actions: Actions = { } return { success: true, message: 'Session révoquée avec succès' }; + }, + sendFriendRequest: async (event) => { + if (!event.locals.user) { + return redirect(302, '/login'); + } + + const formData = await event.request.formData(); + const friendEmail = formData.get('friendEmail')?.toString().trim().toLowerCase() ?? ''; + + if (!friendEmail) { + return fail(400, { message: 'Email requis pour envoyer une demande' }); + } + + const me = event.locals.user; + if (friendEmail === me.email?.toLowerCase()) { + return fail(400, { message: 'Tu ne peux pas t\'ajouter toi-même' }); + } + + const [targetUser] = await db + .select({ id: user.id, email: user.email }) + .from(user) + .where(eq(user.email, friendEmail)) + .limit(1); + + if (!targetUser) { + return fail(404, { message: 'Aucun utilisateur trouvé avec cet email' }); + } + + const [existing] = await db + .select() + .from(friendship) + .where( + or( + and(eq(friendship.requesterId, me.id), eq(friendship.addresseeId, targetUser.id)), + and(eq(friendship.requesterId, targetUser.id), eq(friendship.addresseeId, me.id)) + ) + ) + .limit(1); + + const now = Date.now(); + + if (!existing) { + await db.insert(friendship).values({ + requesterId: me.id, + addresseeId: targetUser.id, + status: 'pending', + createdAt: now, + updatedAt: now + }); + return { success: true, message: 'Demande d\'ami envoyée' }; + } + + if (existing.status === 'accepted') { + return fail(400, { message: 'Vous êtes déjà amis' }); + } + + if (existing.status === 'pending') { + if (existing.requesterId === targetUser.id && existing.addresseeId === me.id) { + await db + .update(friendship) + .set({ + status: 'accepted', + updatedAt: now + }) + .where(eq(friendship.id, existing.id)); + return { success: true, message: 'Demande acceptée automatiquement' }; + } + + return fail(400, { message: 'Demande déjà envoyée' }); + } + + await db + .update(friendship) + .set({ + requesterId: me.id, + addresseeId: targetUser.id, + status: 'pending', + updatedAt: now + }) + .where(eq(friendship.id, existing.id)); + + return { success: true, message: 'Demande d\'ami envoyée' }; + }, + acceptFriendRequest: async (event) => { + if (!event.locals.user) { + return redirect(302, '/login'); + } + + const formData = await event.request.formData(); + const friendshipId = formData.get('friendshipId')?.toString() ?? ''; + + if (!friendshipId) { + return fail(400, { message: 'Demande invalide' }); + } + + const now = Date.now(); + const result = await db + .update(friendship) + .set({ status: 'accepted', updatedAt: now }) + .where( + and( + eq(friendship.id, friendshipId), + eq(friendship.addresseeId, event.locals.user.id), + eq(friendship.status, 'pending') + ) + ) + .returning({ id: friendship.id }); + + if (result.length === 0) { + return fail(404, { message: 'Demande introuvable' }); + } + + return { success: true, message: 'Demande acceptée' }; + }, + declineFriendRequest: async (event) => { + if (!event.locals.user) { + return redirect(302, '/login'); + } + + const formData = await event.request.formData(); + const friendshipId = formData.get('friendshipId')?.toString() ?? ''; + + if (!friendshipId) { + return fail(400, { message: 'Demande invalide' }); + } + + const now = Date.now(); + const result = await db + .update(friendship) + .set({ status: 'declined', updatedAt: now }) + .where( + and( + eq(friendship.id, friendshipId), + eq(friendship.addresseeId, event.locals.user.id), + eq(friendship.status, 'pending') + ) + ) + .returning({ id: friendship.id }); + + if (result.length === 0) { + return fail(404, { message: 'Demande introuvable' }); + } + + return { success: true, message: 'Demande refusée' }; + }, + cancelFriendRequest: async (event) => { + if (!event.locals.user) { + return redirect(302, '/login'); + } + + const formData = await event.request.formData(); + const friendshipId = formData.get('friendshipId')?.toString() ?? ''; + + if (!friendshipId) { + return fail(400, { message: 'Demande invalide' }); + } + + const result = await db + .delete(friendship) + .where( + and( + eq(friendship.id, friendshipId), + eq(friendship.requesterId, event.locals.user.id), + eq(friendship.status, 'pending') + ) + ) + .returning({ id: friendship.id }); + + if (result.length === 0) { + return fail(404, { message: 'Demande introuvable' }); + } + + return { success: true, message: 'Demande annulée' }; + }, + removeFriend: async (event) => { + if (!event.locals.user) { + return redirect(302, '/login'); + } + + const formData = await event.request.formData(); + const friendshipId = formData.get('friendshipId')?.toString() ?? ''; + + if (!friendshipId) { + return fail(400, { message: 'Relation invalide' }); + } + + const result = await db + .delete(friendship) + .where( + and( + eq(friendship.id, friendshipId), + eq(friendship.status, 'accepted'), + or(eq(friendship.requesterId, event.locals.user.id), eq(friendship.addresseeId, event.locals.user.id)) + ) + ) + .returning({ id: friendship.id }); + + if (result.length === 0) { + return fail(404, { message: 'Relation introuvable' }); + } + + return { success: true, message: 'Ami supprimé' }; } }; diff --git a/src/routes/(game)/profile/+page.svelte b/src/routes/(game)/profile/+page.svelte index a2b9d9b..98edd49 100644 --- a/src/routes/(game)/profile/+page.svelte +++ b/src/routes/(game)/profile/+page.svelte @@ -10,14 +10,18 @@ let { data, form }: Props = $props(); let isLoading = $state(false); - let activeTab = $state<'profile' | 'password' | 'sessions' | 'daily'>('profile'); + let activeTab = $state<'profile' | 'password' | 'sessions' | 'daily' | 'friends'>('profile'); let name = $state(''); + let friendEmail = $state(''); let showSuccess = $state(false); let oldPassword = $state(''); let newPassword = $state(''); let confirmPassword = $state(''); let sessions = $state([]); let dailyHistory = $state([]); + let friends = $state([]); + let incomingRequests = $state([]); + let outgoingRequests = $state([]); let tabsElement: HTMLDivElement | undefined; $effect(() => { @@ -32,6 +36,18 @@ dailyHistory = (data as any).dailyHistory || []; }); + $effect(() => { + friends = (data as any).friends || []; + }); + + $effect(() => { + incomingRequests = (data as any).incomingRequests || []; + }); + + $effect(() => { + outgoingRequests = (data as any).outgoingRequests || []; + }); + $effect(() => { if (form && form.success === true) { showSuccess = true; @@ -41,7 +57,7 @@ } }); - const handleTabChange = (tab: 'profile' | 'password' | 'sessions' | 'daily') => { + const handleTabChange = (tab: 'profile' | 'password' | 'sessions' | 'daily' | 'friends') => { activeTab = tab; }; @@ -104,6 +120,14 @@ > Sessions + @@ -184,6 +208,128 @@ {/if} + + {#if activeTab === 'friends'} +
+

+ Système d'amis +

+ +
{ + isLoading = true; + return async ({ update }) => { + isLoading = false; + friendEmail = ''; + await update(); + }; + }} + class="mb-8 space-y-3" + > + +
+ + +
+ {#if form?.message} +

{form.message}

+ {/if} +
+ +
+
+

Demandes reçues

+ {#if incomingRequests.length === 0} +

Aucune demande reçue.

+ {:else} +
+ {#each incomingRequests as req} +
+
+

{req.requesterName}

+

{req.requesterEmail}

+
+
+
+ + +
+
+ + +
+
+
+ {/each} +
+ {/if} +
+ +
+

Demandes envoyées

+ {#if outgoingRequests.length === 0} +

Aucune demande envoyée.

+ {:else} +
+ {#each outgoingRequests as req} +
+
+

{req.addresseeName}

+

{req.addresseeEmail}

+
+
+ + +
+
+ {/each} +
+ {/if} +
+ +
+

Mes amis

+ {#if friends.length === 0} +

Tu n'as pas encore d'amis.

+ {:else} +
+ {#each friends as friend} +
+
+

{friend.friendName}

+

{friend.friendEmail}

+
+
+ + +
+
+ {/each} +
+ {/if} +
+
+
+ {/if} + {#if activeTab === 'password'}