diff --git a/backend/package-lock.json b/backend/package-lock.json index 693b392..b28c402 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "commitflow-api", - "version": "1.1.7", + "version": "1.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "commitflow-api", - "version": "1.1.7", + "version": "1.1.8", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.922.0", @@ -23,10 +23,12 @@ "@nestjs/swagger": "^11.2.1", "@nestjs/websockets": "^11.1.6", "@prisma/client": "^6.18.0", + "@types/cookie-parser": "^1.4.10", "axios": "^1.12.2", "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "cookie-parser": "^1.4.7", "dayjs": "^1.11.19", "dotenv": "^17.2.3", "exceljs": "^4.4.0", @@ -4927,7 +4929,6 @@ "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", @@ -4938,12 +4939,20 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", @@ -4993,7 +5002,6 @@ "version": "5.0.5", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -5005,7 +5013,6 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -5018,7 +5025,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { @@ -5087,7 +5093,6 @@ "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, "license": "MIT" }, "node_modules/@types/ms": { @@ -5130,21 +5135,18 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/send": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz", "integrity": "sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -5154,7 +5156,6 @@ "version": "1.15.9", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.9.tgz", "integrity": "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -5166,7 +5167,6 @@ "version": "0.17.5", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", - "dev": true, "license": "MIT", "dependencies": { "@types/mime": "^1", @@ -7316,6 +7316,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", diff --git a/backend/package.json b/backend/package.json index e5cbdfb..0b985ce 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "commitflow-api", - "version": "1.1.7", + "version": "1.1.8", "description": "Backend CommitFlow", "author": "asepindrak", "private": false, @@ -34,10 +34,12 @@ "@nestjs/swagger": "^11.2.1", "@nestjs/websockets": "^11.1.6", "@prisma/client": "^6.18.0", + "@types/cookie-parser": "^1.4.10", "axios": "^1.12.2", "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "cookie-parser": "^1.4.7", "dayjs": "^1.11.19", "dotenv": "^17.2.3", "exceljs": "^4.4.0", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 144204b..b09b57a 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,36 +1,33 @@ // src/app.module.ts -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { AskModule } from './ai-agent/ask.module'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; -import { UsersModule } from './users/users.module'; +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { AskModule } from "./ai-agent/ask.module"; +import { AppController } from "./app.controller"; +import { AppService } from "./app.service"; +import { UsersModule } from "./users/users.module"; import { AskController } from "./ai-agent/ask.controller"; import { AskService } from "./ai-agent/ask.service"; import { AskGateway } from "./ai-agent/ask.gateway"; -import { ServeStaticModule } from '@nestjs/serve-static'; -import { join } from 'path'; +import { ServeStaticModule } from "@nestjs/serve-static"; +import { join } from "path"; import { AuthModule } from "./auth/auth.module"; import { JwtGuard } from "./common/guards/jwt.guard"; import { SharedModule } from "./common/shared.module"; -import { UploadModule } from './upload/upload.module'; -import { ProjectManagementModule } from './project-management/project-management.module'; -import { EmailModule } from './email/email.module'; +import { UploadModule } from "./upload/upload.module"; +import { ProjectManagementModule } from "./project-management/project-management.module"; +import { EmailModule } from "./email/email.module"; +import { APP_GUARD } from "@nestjs/core"; @Module({ imports: [ - // config global (baca .env) ConfigModule.forRoot({ isGlobal: true, - envFilePath: '.env', + envFilePath: ".env", }), - - // Serve folder public sebagai static files ServeStaticModule.forRoot({ - rootPath: join(__dirname, '..', 'public'), - serveRoot: '/', + rootPath: join(__dirname, "..", "public"), + serveRoot: "/", }), - AuthModule, UsersModule, AskModule, @@ -40,6 +37,15 @@ import { EmailModule } from './email/email.module'; EmailModule, ], controllers: [AppController, AskController], - providers: [AppService, AskService, AskGateway, JwtGuard], + providers: [ + AppService, + AskService, + AskGateway, + // Register JwtGuard as a global guard via APP_GUARD so Reflector and DI work properly + { + provide: APP_GUARD, + useClass: JwtGuard, + }, + ], }) -export class AppModule { } +export class AppModule {} diff --git a/backend/src/app.service.ts b/backend/src/app.service.ts index 8a60fbb..11780e7 100644 --- a/backend/src/app.service.ts +++ b/backend/src/app.service.ts @@ -3,6 +3,6 @@ import { Injectable } from "@nestjs/common"; @Injectable() export class AppService { getHello(): string { - return `CommitFlow API (1.1.7) is running!`; + return `CommitFlow API (1.1.8) is running!`; } } diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index a9718b8..a6de3bd 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -12,6 +12,7 @@ import { AuthService } from "./auth.service"; import { RegisterDto } from "./dto/register.dto"; import { LoginDto } from "./dto/login.dto"; import type { Request, Response } from "express"; +import { Public } from "./public.decorator"; const REFRESH_COOKIE_NAME = "refresh_token"; const COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days in ms @@ -37,6 +38,7 @@ const COOKIE_OPTIONS = { export class AuthController { constructor(private authService: AuthService) {} + @Public() @Post("anon") async anonLogin( @Body("userId") userId?: string, @@ -56,6 +58,7 @@ export class AuthController { return { token: result.token, userId: result.user.id }; } + @Public() @Post("register") async register( @Body() dto: RegisterDto, @@ -77,11 +80,11 @@ export class AuthController { userId: result.user.id, user: result.user, workspaceId: result.workspace.id, - teamMemberId: result.teamMember.id, clientTempId: result.clientTempId ?? null, }; } + @Public() @Post("login") async login( @Body() dto: LoginDto, @@ -104,11 +107,11 @@ export class AuthController { token: result.token, userId: result?.user?.id ?? "", user: result.user, - teamMemberId: result?.teamMemberId, }; } // refresh endpoint: reads refresh_token cookie, verifies, rotates + @Public() @HttpCode(200) @Post("refresh") async refresh( @@ -116,7 +119,9 @@ export class AuthController { @Res({ passthrough: true }) res: Response ) { const token = req.cookies?.[REFRESH_COOKIE_NAME]; - if (!token) throw new UnauthorizedException("No refresh token"); + if (!token) { + throw new UnauthorizedException("No refresh token"); + } // attempt to verify and refresh via AuthService let payload: any; @@ -137,9 +142,14 @@ export class AuthController { }); // return access token - return { token: newTokens.accessToken }; + return { + token: newTokens.token, + userId: newTokens?.user?.id ?? "", + user: newTokens.user, + }; } + @Public() @Post("logout") async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) { const token = req.cookies?.[REFRESH_COOKIE_NAME]; diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 48f000e..77de649 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -9,7 +9,7 @@ import { import { PrismaClient } from "@prisma/client"; import { JwtService } from "@nestjs/jwt"; import { RegisterDto } from "./dto/register.dto"; -import { comparePassword, hashPassword } from "./utils"; +import { comparePassword, hashPassword, tryDecodeJwt } from "./utils"; import { LoginDto } from "./dto/login.dto"; import * as bcrypt from "bcrypt"; @@ -30,7 +30,7 @@ export class AuthService { async generateTokens(userId: string, extra: Record = {}) { const payload = { sub: userId, userId, ...extra }; - const accessToken = this.jwtService.sign(payload, { expiresIn: "15m" }); + const accessToken = this.jwtService.sign(payload, { expiresIn: "1d" }); const refreshToken = this.jwtService.sign(payload, { expiresIn: "7d" }); return { accessToken, refreshToken }; } @@ -76,11 +76,30 @@ export class AuthService { if (!match) return null; // rotation: issue new tokens and replace stored hash - const { accessToken, refreshToken } = await this.generateTokens(userId, { - teamMemberId: payload.teamMemberId ?? undefined, - }); + const { accessToken, refreshToken } = await this.generateTokens(userId); + + const accessPayload = tryDecodeJwt(accessToken); + const refreshPayload = tryDecodeJwt(refreshToken); + + // save new refresh token hash + expiry await this.saveRefreshToken(userId, refreshToken); - return { accessToken, refreshToken }; + + await this.prisma.user.update({ + where: { id: userId }, + data: { session_token: accessToken }, + }); + + const teamMember: any = await this.prisma.teamMember.findFirst({ + where: { userId }, + }); + if (!teamMember) throw new UnauthorizedException("Invalid credentials"); + + return { + token: accessToken, + refreshToken: refreshToken, + user, + teamMember, + }; } // ---------------- existing methods (adapted to return refresh token) ---------------- @@ -95,9 +114,9 @@ export class AuthService { user = await this.prisma.user.create({ data: { id: clientUserId || undefined }, }); - console.log("Created new anonymous user:", user.id); + // console.log("Created new anonymous user:", user.id); } else { - console.log("Existing user found:", user.id); + // console.log("Existing user found:", user.id); } // generate tokens (access + refresh) @@ -124,7 +143,6 @@ export class AuthService { if (existing) throw new ConflictException("Email already registered"); try { - console.log(workspace); const result = await this.prisma.$transaction(async (tx) => { const createWorkspace = await tx.workspace.create({ data: { name: workspace, createdAt: new Date() }, @@ -160,9 +178,7 @@ export class AuthService { }); // issue tokens and save refresh - const tokens = await this.generateTokens(result.user.id, { - teamMemberId: result.teamMember.id, - }); + const tokens = await this.generateTokens(result.user.id); await this.saveRefreshToken(result.user.id, tokens.refreshToken); // Save access token in session_token for compat @@ -176,7 +192,6 @@ export class AuthService { refreshToken: tokens.refreshToken, user: result.user, teamMember: result.teamMember, - teamMemberId: result.teamMember.id, workspace: result.workspace, clientTempId, }; @@ -215,9 +230,7 @@ export class AuthService { }); if (!teamMember) throw new UnauthorizedException("Invalid credentials"); - const tokens = await this.generateTokens(user.id, { - teamMemberId: teamMember.id, - }); + const tokens = await this.generateTokens(user.id); await this.saveRefreshToken(user.id, tokens.refreshToken); await this.prisma.user.update({ @@ -234,7 +247,6 @@ export class AuthService { refreshToken: tokens.refreshToken, user, teamMember, - teamMemberId: teamMember.id, }; } diff --git a/backend/src/auth/public.decorator.ts b/backend/src/auth/public.decorator.ts new file mode 100644 index 0000000..4032c03 --- /dev/null +++ b/backend/src/auth/public.decorator.ts @@ -0,0 +1,5 @@ +// src/auth/public.decorator.ts +import { SetMetadata } from "@nestjs/common"; + +export const IS_PUBLIC_KEY = "isPublic"; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/backend/src/auth/utils.ts b/backend/src/auth/utils.ts index b41433a..f0350ae 100644 --- a/backend/src/auth/utils.ts +++ b/backend/src/auth/utils.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ import { randomBytes, scryptSync } from "crypto"; export function hashPassword(password: string) { @@ -11,3 +12,16 @@ export function comparePassword(password: string, stored: string) { const hashed = scryptSync(password, salt, 64).toString("hex"); return hashed === hash; } + +export function tryDecodeJwt(token: string | null) { + if (!token) return null; + try { + const b = token.split(".")[1]; + // base64url -> base64 + const base64 = b.replace(/-/g, "+").replace(/_/g, "/"); + const json = Buffer.from(base64, "base64").toString("utf8"); + return JSON.parse(json); + } catch (e) { + return null; + } +} diff --git a/backend/src/common/guards/jwt.guard.ts b/backend/src/common/guards/jwt.guard.ts index dc89215..3a54ade 100644 --- a/backend/src/common/guards/jwt.guard.ts +++ b/backend/src/common/guards/jwt.guard.ts @@ -1,31 +1,90 @@ // src/common/guards/jwt.guard.ts -import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; -import { PrismaClient } from '@prisma/client'; +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from "@nestjs/common"; +import { JwtService } from "@nestjs/jwt"; +import { PrismaClient } from "@prisma/client"; +import { tryDecodeJwt } from "src/auth/utils"; +import { Reflector } from "@nestjs/core"; +import { IS_PUBLIC_KEY } from "src/auth/public.decorator"; @Injectable() export class JwtGuard implements CanActivate { - constructor( - private jwtService: JwtService, - private prisma: PrismaClient, - ) { } - - async canActivate(context: ExecutionContext): Promise { - const req = context.switchToHttp().getRequest(); - const authHeader = req.headers['authorization'] || ''; - const token = authHeader.replace('Bearer ', ''); - - if (!token) throw new UnauthorizedException('No token'); - - try { - const payload: any = this.jwtService.verify(token); - const user = await this.prisma.user.findUnique({ where: { id: payload.userId } }); - if (!user || user.session_token !== token) throw new UnauthorizedException('Invalid token'); - - req.user = payload; - return true; - } catch { - throw new UnauthorizedException('Invalid or expired token'); - } + constructor( + private jwtService: JwtService, + private prisma: PrismaClient, + private reflector: Reflector + ) {} + + async canActivate(context: ExecutionContext): Promise { + // If route marked public, skip guard + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) { + return true; + } + + const req = context.switchToHttp().getRequest(); + const authHeader = req.headers["authorization"] || ""; + const token = + typeof authHeader === "string" + ? authHeader.replace(/^Bearer\s+/i, "") + : ""; + + if (!token) { + console.error("[JwtGuard] No token supplied"); + throw new UnauthorizedException("No token"); + } + + try { + const payload: any = this.jwtService.verify(token); + + const userId = payload.sub ?? payload.userId; + if (!userId) { + console.error("[JwtGuard] payload missing userId/sub", payload); + throw new UnauthorizedException("Invalid token payload"); + } + + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + console.error("[JwtGuard] no user for id", userId); + throw new UnauthorizedException("Invalid token"); + } + + if (user.session_token && user.session_token !== token) { + console.warn("[JwtGuard] session_token mismatch for user", userId); + throw new UnauthorizedException("Invalid token (session mismatch)"); + } + + req.user = payload; + return true; + } catch (err: any) { + if (err?.name === "TokenExpiredError") { + console.error("[JwtGuard] TokenExpiredError:", { + serverTime: new Date().toISOString(), + expiredAt: err.expiredAt ? err.expiredAt.toISOString() : null, + }); + } else { + console.error("[JwtGuard] verify error:", err && (err.message ?? err)); + } + + const busted = tryDecodeJwt(token); + if (busted) { + console.error( + "[JwtGuard] token (unverified) iat/exp:", + busted.iat, + busted.exp, + "expDate:", + busted.exp ? new Date(busted.exp * 1000).toISOString() : null + ); + } + + throw new UnauthorizedException("Invalid or expired token"); } + } } diff --git a/backend/src/common/guards/session.guard.ts b/backend/src/common/guards/session.guard.ts index a2dc9ca..3f51ae4 100644 --- a/backend/src/common/guards/session.guard.ts +++ b/backend/src/common/guards/session.guard.ts @@ -1,30 +1,33 @@ // src/common/guards/session.guard.ts -import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from "@nestjs/common"; globalThis.__sessions = globalThis.__sessions || new Map(); @Injectable() export class SessionGuard implements CanActivate { - canActivate(context: ExecutionContext): boolean { - const req = context.switchToHttp().getRequest(); - const token = req.headers['x-session-token']; - console.log(`[SessionGuard] Active sessions: ${Array.from(globalThis.__sessions.keys()).join(', ')}`); - const sessions = globalThis.__sessions; + canActivate(context: ExecutionContext): boolean { + const req = context.switchToHttp().getRequest(); + const token = req.headers["x-session-token"]; + const sessions = globalThis.__sessions; - if (!token || !sessions.has(token)) { - throw new UnauthorizedException('Invalid session token'); - } - - const expires = sessions.get(token)!; - if (Date.now() > expires) { - sessions.delete(token); - throw new UnauthorizedException('Session expired'); - } - - return true; + if (!token || !sessions.has(token)) { + throw new UnauthorizedException("Invalid session token"); } - static addSession(token: string, ttlMs = 10 * 60 * 1000) { - globalThis.__sessions.set(token, Date.now() + ttlMs); - console.log(`[SessionGuard] Added token ${token}`); + const expires = sessions.get(token)!; + if (Date.now() > expires) { + sessions.delete(token); + throw new UnauthorizedException("Session expired"); } + + return true; + } + + static addSession(token: string, ttlMs = 10 * 60 * 1000) { + globalThis.__sessions.set(token, Date.now() + ttlMs); + } } diff --git a/backend/src/main.ts b/backend/src/main.ts index e5101bb..5d9e1ce 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -4,6 +4,8 @@ import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; // import { ApiKeyGuard } from './common/guards/api-key.guard'; import { ValidationPipe } from "@nestjs/common"; +import cookieParser from "cookie-parser"; + async function bootstrap() { const app = await NestFactory.create(AppModule); // app.useGlobalGuards(new ApiKeyGuard(new Reflector())); @@ -30,6 +32,8 @@ async function bootstrap() { allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"], }); + app.use(cookieParser()); + const config = new DocumentBuilder() .setTitle("CommitFlow API") .setDescription("Dokumentasi API Otomatis dengan Swagger") diff --git a/backend/src/project-management/project-management.service.ts b/backend/src/project-management/project-management.service.ts index e8e4294..b54bd01 100644 --- a/backend/src/project-management/project-management.service.ts +++ b/backend/src/project-management/project-management.service.ts @@ -343,15 +343,6 @@ export class ProjectManagementService { clientId?: string | null; // optional client-generated id for idempotency }> ) { - // quick debug log (remove in production when stable) - console.log("[createTask] incoming", { - clientId: payload.clientId, - projectId: payload.projectId, - assigneeId: payload.assigneeId, - title: payload.title, - ts: new Date().toISOString(), - }); - // validate projectId/assignee exist if provided (and not null) if ( typeof payload.projectId !== "undefined" && @@ -401,8 +392,6 @@ export class ProjectManagementService { } } - console.log("assigneeId", assigneeId); - // Create task (store startDate/dueDate as strings to match Prisma schema) const t = await prisma.task.create({ data: { @@ -425,13 +414,6 @@ export class ProjectManagementService { }, }); - console.log( - "[createTask] created id=", - t.id, - "clientId=", - payload.clientId ?? null - ); - // return serialized created task (timestamps as ISO) return { ...t, @@ -473,7 +455,7 @@ export class ProjectManagementService { }); if (!project) throw new NotFoundException("Project not found"); } - console.log(project); + let assignee: any = null; // validate assignee if present and not null if ( @@ -580,9 +562,11 @@ export class ProjectManagementService { } if (assigneeId !== data.assigneeId) { - emailTitle = `👤 Task Assignee has Changed to ${assignee.name ?? "none"}`; + emailTitle = `👤 Task Assignee has Changed to ${ + assignee?.name ?? "none" + }`; emailDescription = `👤 A task Assignee has been changed to ${ - assignee.name ?? "none" + assignee?.name ?? "none" } on ${projectName}.`; } @@ -595,11 +579,11 @@ export class ProjectManagementService { Description: ${updated.description ?? "No description"} - Status: ${updated.status} - Assignee: ${assignee.name ?? "none"} - Priority: ${updated.priority ?? "none"} - Start Date: ${format(updated.startDate)} - Due Date: ${format(updated.dueDate)} + Status: ${updated?.status} + Assignee: ${assignee?.name ?? "none"} + Priority: ${updated?.priority ?? "none"} + Start Date: ${format(updated?.startDate)} + Due Date: ${format(updated?.dueDate)} Project: ${projectName} @@ -634,22 +618,22 @@ export class ProjectManagementService {

Status: ${ - updated.status + updated?.status }
Assignee: ${ - assignee.name ?? "none" + assignee?.name ?? "none" }
Priority: ${ - updated.priority ?? "none" + updated?.priority ?? "none" }

Start Date: ${format( - updated.startDate + updated?.startDate )}
Due Date: ${format( - updated.dueDate + updated?.dueDate )}

@@ -678,7 +662,6 @@ export class ProjectManagementService { `; - console.log(textMsg); // KIRIM EMAIL for (const recipient of toEmails) { await this.email.sendMail({ @@ -1002,7 +985,7 @@ export class ProjectManagementService { }, }); } - console.log(user); + // create team member const tm = await tx.teamMember.create({ data: { @@ -1047,7 +1030,6 @@ export class ProjectManagementService { photo?: string; }> ) { - console.log(payload); // 1) ensure team member exists const exists = await prisma.teamMember.findUnique({ where: { id } }); if (!exists) throw new NotFoundException("Team member not found"); @@ -1101,7 +1083,7 @@ export class ProjectManagementService { }); } } - console.log(user); + return { teamMember: updatedTeam, user }; }); @@ -1342,17 +1324,11 @@ export class ProjectManagementService { include: { assignee: true }, orderBy: { createdAt: "desc" }, }); - console.log(tasks); + const team = await prisma.teamMember.findMany({ where: { isTrash: false }, orderBy: { name: "asc" }, }); - console.log(team); - - // debug logs to help diagnose empty-sheet issues - console.log("[exportXlsx] projects:", projects.length); - console.log("[exportXlsx] tasks:", tasks.length); - console.log("[exportXlsx] team:", team.length); const wb = new ExcelJS.Workbook(); diff --git a/backend/src/upload/upload.controller.ts b/backend/src/upload/upload.controller.ts index 583400a..a5df5b8 100644 --- a/backend/src/upload/upload.controller.ts +++ b/backend/src/upload/upload.controller.ts @@ -1,53 +1,65 @@ // src/upload/upload.controller.ts import { - Controller, - Post, - UseInterceptors, - UploadedFiles, - Body, - BadRequestException, - HttpCode, - HttpStatus, -} from '@nestjs/common'; -import { FilesInterceptor } from '@nestjs/platform-express'; -import { UploadService } from './upload.service'; -import multer from 'multer'; + Controller, + Post, + UseInterceptors, + UploadedFiles, + Body, + BadRequestException, + HttpCode, + HttpStatus, +} from "@nestjs/common"; +import { FilesInterceptor } from "@nestjs/platform-express"; +import { UploadService } from "./upload.service"; +import multer from "multer"; +import { Public } from "src/auth/public.decorator"; -@Controller('upload') +@Controller("upload") export class UploadController { - constructor(private readonly uploadService: UploadService) { } + constructor(private readonly uploadService: UploadService) {} - /** - * POST /upload - * multipart form: - * - file: single or multiple files (use input name "file") - * - folder: optional form field for prefix - * - * Response: - * { success: true, uploaded: [ { key, url }, ... ] } - */ - @Post() - @HttpCode(HttpStatus.OK) - @UseInterceptors( - FilesInterceptor('file', 20, { - storage: multer.memoryStorage(), - limits: { fileSize: 50 * 1024 * 1024 }, // max 50MB per file (tweak as needed) - }), - ) - async uploadFiles(@UploadedFiles() files: Express.Multer.File[], @Body('folder') folder?: string) { - if (!files || files.length === 0) { - throw new BadRequestException('No file provided. Use field name "file" in multipart form.'); - } + /** + * POST /upload + * multipart form: + * - file: single or multiple files (use input name "file") + * - folder: optional form field for prefix + * + * Response: + * { success: true, uploaded: [ { key, url }, ... ] } + */ + @Public() + @Post() + @HttpCode(HttpStatus.OK) + @UseInterceptors( + FilesInterceptor("file", 20, { + storage: multer.memoryStorage(), + limits: { fileSize: 50 * 1024 * 1024 }, // max 50MB per file (tweak as needed) + }) + ) + async uploadFiles( + @UploadedFiles() files: Express.Multer.File[], + @Body("folder") folder?: string + ) { + if (!files || files.length === 0) { + throw new BadRequestException( + 'No file provided. Use field name "file" in multipart form.' + ); + } - // sanitize folder - disallow leading ../ for safety - const safeFolder = folder ? folder.replace(/\.\.+/g, '').replace(/^\/+/, '') : ''; + // sanitize folder - disallow leading ../ for safety + const safeFolder = folder + ? folder.replace(/\.\.+/g, "").replace(/^\/+/, "") + : ""; - const uploaded = await this.uploadService.uploadMultipleFiles(files, safeFolder); + const uploaded = await this.uploadService.uploadMultipleFiles( + files, + safeFolder + ); - return { - success: true, - count: uploaded.length, - uploaded, - }; - } + return { + success: true, + count: uploaded.length, + uploaded, + }; + } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 85a7936..065aa48 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "commitflow-ui", - "version": "1.3.7", + "version": "1.3.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "commitflow-ui", - "version": "1.3.7", + "version": "1.3.8", "license": "MIT", "dependencies": { "@gsap/react": "^2.1.2", diff --git a/frontend/package.json b/frontend/package.json index 5a45988..1c7b415 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "author": "asepindrak", "private": false, "license": "MIT", - "version": "1.3.7", + "version": "1.3.8", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 354753e..608012a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -86,7 +86,7 @@ function App() { }; const handleAuth = (r: any) => { - // r adalah AuthResult dari backend: { token, userId, teamMemberId?, ... } + // r adalah AuthResult dari backend: { token, userId, ... } if (!r || !r.token) { console.warn("handleAuth: invalid auth result", r); return; @@ -97,7 +97,6 @@ function App() { token: r.token, userId: r.userId, user: r.user, - teamMemberId: r.teamMemberId ?? null, }); // update UI diff --git a/frontend/src/api/authApi.ts b/frontend/src/api/authApi.ts index 89c1817..4d0fa11 100644 --- a/frontend/src/api/authApi.ts +++ b/frontend/src/api/authApi.ts @@ -29,7 +29,6 @@ export type AuthResult = { token: string; userId: string; user: any | null; - teamMemberId?: string | null; clientTempId?: string | null; }; diff --git a/frontend/src/components/AssigneeSelect.tsx b/frontend/src/components/AssigneeSelect.tsx index 471c9d2..0ae6394 100644 --- a/frontend/src/components/AssigneeSelect.tsx +++ b/frontend/src/components/AssigneeSelect.tsx @@ -9,125 +9,127 @@ import type { TeamMember } from "../types"; */ function hashStr(s: string) { - // djb2-ish - let h = 5381; - for (let i = 0; i < s.length; i++) { - h = (h * 33) ^ s.charCodeAt(i); - } - return Math.abs(h); + // djb2-ish + let h = 5381; + for (let i = 0; i < s.length; i++) { + h = (h * 33) ^ s.charCodeAt(i); + } + return Math.abs(h); } // hsl helpers function hsl(h: number, s = 80, l = 50) { - return `hsl(${h} ${s}% ${l}%)`; + return `hsl(${h} ${s}% ${l}%)`; } function hsla(h: number, s = 80, l = 50, a = 1) { - return `hsla(${h} ${s}% ${l}% / ${a})`; + return `hsla(${h} ${s}% ${l}% / ${a})`; } export function AssigneeSelect({ - value, - onChange, - dark, - team, + value, + onChange, + dark, + team, }: { - value?: string; - onChange: (v?: string) => void; - dark: boolean; - team: TeamMember[]; + value?: string; + onChange: (v?: string) => void; + dark: boolean; + team: TeamMember[]; }) { - const styles = makeSelectStyles(dark); - const theme = makeSelectTheme(dark); + const styles = makeSelectStyles(dark); + const theme = makeSelectTheme(dark); - const options = team.map((t: TeamMember) => { - const hue = hashStr(t.name) % 360; // 0..359 - // choose visible text color and dot background depending on mode: - const dot = dark ? hsla(hue, 70, 55, 0.95) : hsl(hue, 75, 45); - const text = dark ? hsl(hue, 70, 75) : hsl(hue, 75, 25); - return { value: t.id, label: t.name, meta: { hue, dot, text } }; - }); + const options = team.map((t: TeamMember) => { + const hue = hashStr(t.name) % 360; // 0..359 + // choose visible text color and dot background depending on mode: + const dot = dark ? hsla(hue, 70, 55, 0.95) : hsl(hue, 75, 45); + const text = dark ? hsl(hue, 70, 75) : hsl(hue, 75, 25); + return { value: t.id, label: t.name, meta: { hue, dot, text } }; + }); - // debug: lihat mapping di console - console.debug("Assignee colors:", options.map(o => ({ name: o.value, ...o.meta }))); - - const formatOptionLabel = (opt: any) => { - const dot = opt.meta?.dot; - return ( -
- - {opt.label} -
- ); - }; - - const SingleValue = (props: any) => { - const opt = props.data; - const dot = opt.meta?.dot; - const text = opt.meta?.text; - return ( -
- - {opt.label} -
- ); - }; - - // keep control sizing consistent - const mergedStyles = { - ...styles, - control: (provided: any, state: any) => ({ - ...provided, - minHeight: 38, - height: 38, - borderRadius: 8, - paddingLeft: 6, - paddingRight: 6, - backgroundColor: "transparent", - boxShadow: state.isFocused ? "0 0 0 1px #0ea5e9" : "none", - borderColor: state.isFocused ? "#0ea5e9" : provided.borderColor, - }), - valueContainer: (provided: any) => ({ - ...provided, - padding: "0 6px", - height: 38, - display: "flex", - alignItems: "center", - }), - singleValue: (provided: any) => ({ - ...provided, - display: "flex", - alignItems: "center", - gap: 8, - lineHeight: 1, - margin: 0, - padding: 0, - }), - input: (provided: any) => ({ ...provided, margin: 0, padding: 0 }), - indicatorsContainer: (provided: any) => ({ ...provided, height: 38 }), - }; + const formatOptionLabel = (opt: any) => { + const dot = opt.meta?.dot; + return ( +
+ + {opt.label} +
+ ); + }; + const SingleValue = (props: any) => { + const opt = props.data; + const dot = opt.meta?.dot; + const text = opt.meta?.text; return ( - o.value === value) : null} + onChange={(opt: any) => onChange(opt ? opt.value : undefined)} + isClearable + styles={mergedStyles} + theme={theme} + formatOptionLabel={formatOptionLabel} + components={{ SingleValue }} + classNamePrefix="cf-select" + /> + ); } diff --git a/frontend/src/components/Auth/AuthCard.tsx b/frontend/src/components/Auth/AuthCard.tsx index 56f265e..1a601c1 100644 --- a/frontend/src/components/Auth/AuthCard.tsx +++ b/frontend/src/components/Auth/AuthCard.tsx @@ -118,7 +118,7 @@ export default function AuthCard({ onAuthSuccess, initialEmail }: Props) { toast.error(err?.message || "Login gagal"); return; } - console.log(result); + onAuthSuccess?.(result); } catch (err: any) { console.error(err); diff --git a/frontend/src/components/ChatWindow.tsx b/frontend/src/components/ChatWindow.tsx index 260320f..c1393cc 100644 --- a/frontend/src/components/ChatWindow.tsx +++ b/frontend/src/components/ChatWindow.tsx @@ -155,7 +155,7 @@ Siap bantu insight lebih cerdas. 💡`; setMessages(() => []); return; } - console.log("Fetched messages:", data); + setMessages(() => data); setIsMessagesReady(true); }; diff --git a/frontend/src/components/ProjectManagement.tsx b/frontend/src/components/ProjectManagement.tsx index 5db5188..0bb8c69 100644 --- a/frontend/src/components/ProjectManagement.tsx +++ b/frontend/src/components/ProjectManagement.tsx @@ -1,3 +1,4 @@ +/* eslint-disable no-empty */ import React, { useEffect, useRef, useState } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import Sidebar from "./Sidebar"; @@ -62,7 +63,6 @@ export default function ProjectManagement({ const [showEditMember, setShowEditMember] = useState(false); const [showInviteLink, setShowInviteLink] = useState(false); const [editMember, setEditMember] = useState(null); - const teamMemberId = useAuthStore((s) => s.teamMemberId); const [workspaces, setWorkspaces] = useState([]); const [projects, setProjects] = useState([]); const [tasks, setTasks] = useState([]); @@ -82,27 +82,22 @@ export default function ProjectManagement({ const pendingPosRef = useRef<{ x: number; y: number; width: number } | null>( null ); - console.log(team); - const currentMember = team.find((t) => t.id === teamMemberId); const token = useAuthStore((s) => s.token); const userId = useAuthStore((s) => s.userId); const user = useAuthStore((s) => s.user); const logout = useAuthStore((s) => s.logout); - console.log(user); - console.log(currentMember?.name); + const userWorkspace = team.filter( (item: any) => item.workspaceId === activeWorkspaceId && item.userId === userId ); - console.log("userWorkspace", userWorkspace); const userWorkspaceActive = - userWorkspace.length > 0 ? userWorkspace[0] : user; + userWorkspace.length > 0 ? userWorkspace[0] : user ? user : {}; // alert(userWorkspaceActive.isAdmin); const isAdmin = userWorkspaceActive?.isAdmin ?? false; - console.log("userWorkspace", userWorkspaceActive); if (!userWorkspaceActive?.photo) { userWorkspaceActive.photo = user?.photo ?? null; } @@ -111,7 +106,6 @@ export default function ProjectManagement({ const userPhoto = user?.photo ? user?.photo : userWorkspaceActive.photo || null; - console.log(userInitial); const authTeamMemberId = userWorkspaceActive.id || null; useEffect(() => { @@ -174,7 +168,6 @@ export default function ProjectManagement({ }; const openEditProfileTeam = async (member: any) => { - console.log("member", member); setShowProfileMenu(false); setEditMember(member ?? null); @@ -203,14 +196,11 @@ export default function ProjectManagement({ password: updated.password ?? null, }; const saved = await api.updateTeamMember(updated.id, payload); - console.log("saved", token); - console.log("saved", userId); setAuth({ token: token ?? "", userId: userId ?? "", user: saved.user, - teamMemberId: teamMemberId ?? null, }); setTeam((prev) => prev.map((t) => (t.id === saved.id ? saved : t))); window.location.reload(); @@ -675,7 +665,6 @@ export default function ProjectManagement({ // dipassing ke KanbanBoard via TaskView function handleDragStart(e: React.DragEvent, id: string) { - console.log("handleDragStart", id); setDragTaskId(id); try { @@ -1309,24 +1298,104 @@ export default function ProjectManagement({ }, [dark]); useEffect(() => { - if (tasksQuery.data && Array.isArray(tasksQuery.data)) { - setTasks((localTasks) => { - const serverTasks = tasksQuery.data as Task[]; - const tmp = localTasks.filter((t) => nid(t.id).startsWith("tmp_")); - const others = localTasks.filter( - (t) => - !nid(t.id).startsWith("tmp_") && t.projectId !== activeProjectId + if (!tasksQuery.data || !Array.isArray(tasksQuery.data)) return; + + // Snapshot localTasks to compute merge (we'll still call setTasks with final array) + setTasks((localTasks) => { + const serverTasks = tasksQuery.data as Task[]; + + const tmp = localTasks.filter((t) => nid(t.id).startsWith("tmp_")); + const others = localTasks.filter( + (t) => !nid(t.id).startsWith("tmp_") && t.projectId !== activeProjectId + ); + + const localById = new Map(localTasks.map((t) => [nid(t.id), t])); + const serverById = new Map(serverTasks.map((t) => [nid(t.id), t])); + + const mergedServerTasks: Task[] = serverTasks.map((st) => { + const id = nid(st.id); + const lt = localById.get(id); + + const serverComments: any[] = Array.isArray((st as any).comments) + ? (st as any).comments + : []; + const localComments: any[] = + lt && Array.isArray((lt as any).comments) ? (lt as any).comments : []; + + const maxCreatedAt = (arr: any[]) => { + if (!arr || arr.length === 0) return null; + try { + return arr + .map((c) => c?.createdAt ?? c?.created_at ?? null) + .filter(Boolean) + .sort() + .slice(-1)[0]; + } catch { + return null; + } + }; + + const latestServer = maxCreatedAt(serverComments); + const latestLocal = maxCreatedAt(localComments); + + const tmpLocalOnly = localComments.filter( + (c: any) => + String(c.id).startsWith("c_tmp_") && + !serverComments.some((sc: any) => nid(sc.id) === nid(c.id)) ); - const merged = [ - ...others, - ...serverTasks, - ...tmp.filter((t) => t.projectId === activeProjectId), - ]; - const map = new Map(); - for (const t of merged) map.set(nid(t.id), t); - return Array.from(map.values()); + + let chosenComments: any[] = serverComments; + + if ( + localComments.length > 0 && + (!latestServer || (latestLocal && latestLocal > latestServer)) + ) { + chosenComments = localComments; + } else if (serverComments.length > 0) { + chosenComments = serverComments; + } else if (localComments.length > 0) { + chosenComments = localComments; + } else { + chosenComments = []; + } + + if (tmpLocalOnly.length > 0) { + const existingIds = new Set( + chosenComments.map((c: any) => nid(c.id)) + ); + const toAppend = tmpLocalOnly.filter( + (c: any) => !existingIds.has(nid(c.id)) + ); + if (toAppend.length > 0) + chosenComments = [...chosenComments, ...toAppend]; + } + + return { ...st, comments: chosenComments }; }); - } + + const merged = [ + ...others, + ...mergedServerTasks, + ...tmp.filter((t) => t.projectId === activeProjectId), + ]; + + // build map for quick lookup + const mergedMap = new Map(); + for (const t of merged) mergedMap.set(nid(t.id), t); + + // update selectedTask to the merged object if currently selected + setSelectedTask((cur) => { + if (!cur) return cur; + const found = mergedMap.get(nid(cur.id)); + // if found, return merged object (fresh ref) so modal sees up-to-date comments + return found ?? cur; + }); + + // finally return canonical merged array (deduped by id) + const map = new Map(); + for (const t of merged) map.set(nid(t.id), t); + return Array.from(map.values()); + }); }, [tasksQuery.data, activeProjectId]); async function handleAddTask(title: string) { @@ -1396,13 +1465,20 @@ export default function ProjectManagement({ } async function handleUpdateTask(updated: Task) { - setTasks((s) => - s.map((t) => (nid(t.id) === nid(updated.id) ? updated : t)) - ); + // apply local update and ensure selectedTask updated as well + setTasks((s) => { + const next = s.map((t) => (nid(t.id) === nid(updated.id) ? updated : t)); + // also update selectedTask to the same reference so modal sees latest immediately + setSelectedTask((cur) => + cur && nid(cur.id) === nid(updated.id) ? updated : cur + ); + return next; + }); + // handle tmp id case by enqueueing if (nid(updated.id).startsWith("tmp_")) { try { - const patchToQueue = { + const patchToQueue: any = { id: updated.id, patch: { title: updated.title, @@ -1425,17 +1501,29 @@ export default function ProjectManagement({ : String((updated as any).dueDate), }, }; + + // include comments if present on updated (may be [] or array) + if ( + Object.prototype.hasOwnProperty.call(updated || {}, "comments") || + "comments" in (updated || {}) + ) { + patchToQueue.patch.comments = (updated as any).comments ?? null; + } else { + console.log("[handleUpdateTask/tmp] no comments in updated"); + } + enqueueOp({ op: "update_task", payload: patchToQueue, createdAt: new Date().toISOString(), }); - } catch (_) { - console.log("handle update task enqueue failed"); + } catch (e) { + console.log("handle update task enqueue failed", e); } return; } + // normal branch: build patch and call mutation try { const patch: any = {}; patch.title = updated.title; @@ -1463,16 +1551,108 @@ export default function ProjectManagement({ : String((updated as any).dueDate); } - await updateTaskMutation.mutateAsync({ id: updated.id, patch }); + // include comments when provided (array|null) + if ( + Object.prototype.hasOwnProperty.call(updated || {}, "comments") || + "comments" in (updated || {}) + ) { + patch.comments = (updated as any).comments ?? null; + } else { + console.log("[handleUpdateTask] no comments property on updated"); + } + + const result = await updateTaskMutation.mutateAsync({ + id: updated.id, + patch, + }); + + // Merge updated into react-query cache so a near-immediate refetch won't clobber optimistic comments + try { + // Prefer server result; but preserve existing comments if server omitted them + qcRef.current.setQueryData(["tasks", activeProjectId], (old: any) => { + if (!Array.isArray(old)) return old; + return (old as Task[]).map((t) => { + if (nid(t.id) === nid(updated.id)) { + const merged = { + ...t, // baseline local + ...result, // server authoritative fields + // ensure comments fallback: prefer server.comments, else local t.comments + comments: + result && typeof result.comments !== "undefined" + ? result.comments + : t.comments, + }; + return merged; + } + return t; + }); + }); + } catch (e) { + console.warn("[handleUpdateTask] merge to tasks cache failed", e); + } + + // Update any single-task/detail cache key too (if used) + try { + qcRef.current.setQueryData(["task", updated.id], (old: any) => { + if (!old) return result; + return { + ...old, + ...result, + comments: + result && typeof result.comments !== "undefined" + ? result.comments + : old.comments, + }; + }); + console.log( + "[handleUpdateTask] merged server result into single-task cache" + ); + } catch (e) { + // ignore + } + + // ensure selectedTask references updated object + console.log("[handleUpdateTask] updating selectedTask to updated"); + // update selectedTask to server result (prefer result so comments kept) + setSelectedTask((cur) => + cur && nid(cur.id) === nid(updated.id) + ? { + ...cur, + ...result, + comments: + result && typeof result.comments !== "undefined" + ? result.comments + : cur.comments, + } + : cur + ); } catch (err) { + console.error( + "handleUpdateTask: updateTaskMutation failed, enqueueing fallback", + err + ); try { enqueueOp({ op: "update_task", payload: { id: updated.id, patch: updated }, createdAt: new Date().toISOString(), }); - } catch (_) { - console.log("handle update task failed"); + } catch (e) { + console.log("handle update task failed", e); + } + } finally { + // Delay invalidation a little so our cache merge has effect before refetch + try { + setTimeout(() => { + try { + qcRef.current.invalidateQueries(["tasks", activeProjectId]); + console.log("[handleUpdateTask] invalidateQueries fired (delayed)"); + } catch (e) { + console.warn("invalidateQueries failed", e); + } + }, 300); + } catch (e) { + console.warn("invalidate scheduling failed", e); } } } @@ -1520,7 +1700,7 @@ export default function ProjectManagement({ try { const created = await api.inviteTeamMember({ ...m, clientId: m.id }); - console.log("inviteTeamMember returned:", created); + if (!created.success) { Swal.fire({ title: "Member Exists", @@ -2405,61 +2585,111 @@ export default function ProjectManagement({ setSelectedTask(null); }} onAddComment={async (author, body, attachments) => { + const taskId = selectedTask?.id; + if (!taskId) { + console.warn("onAddComment: no selectedTask available"); + return; + } + + const tmpId = `c_tmp_${Math.random() + .toString(36) + .slice(2, 9)}`; const tmpComment = { - id: `c_tmp_${Math.random().toString(36).slice(2, 9)}`, + id: tmpId, author, body, createdAt: new Date().toISOString(), - attachments, + attachments: attachments ?? [], }; - setTasks((s) => - s.map((t) => - nid(t.id) === nid(selectedTask!.id) + // optimistic update tasks + also update selectedTask so modal sees it + setTasks((prev) => { + const next = prev.map((t) => + nid(t.id) === nid(taskId) ? { ...t, - comments: [...(t.comments || []), tmpComment], + comments: [tmpComment, ...(t.comments || [])], } : t - ) - ); + ); - playSound("/sounds/send.mp3", isPlaySound); + // update selectedTask to the new object reference (so modal receives new prop) + const updated = + next.find((t) => nid(t.id) === nid(taskId)) ?? null; + setSelectedTask(updated); - const latest = - tasks.find((t) => nid(t.id) === nid(selectedTask!.id)) || - selectedTask!; + // persist snapshot so close->open reads the updated tasks immediately + try { + const snapshotRaw = localStorage.getItem( + "commitflow_local_snapshot" + ); + const snap = snapshotRaw ? JSON.parse(snapshotRaw) : {}; + snap.tasks = next; + localStorage.setItem( + "commitflow_local_snapshot", + JSON.stringify(snap) + ); + } catch (e) { + console.warn("persist snapshot failed", e); + } + + return next; + }); + + playSound("/sounds/send.mp3", isPlaySound); try { - const created = await api.createComment(latest.id, { + // call API + const created = await api.createComment(taskId, { author, body, attachments, }); - setTasks((s) => - s.map((t) => { - if (nid(t.id) !== nid(latest.id)) return t; - const cs = (t.comments || []).map((c) => - c.id === tmpComment.id ? created : c + + // replace tmp comment with server-created comment, update selectedTask too + setTasks((prev) => { + const next = prev.map((t) => { + if (nid(t.id) !== nid(taskId)) return t; + const cs = (t.comments || []).map((c: any) => + c.id === tmpId ? created : c + ); + const has = cs.some( + (c: any) => nid(c.id) === nid(created.id) ); - const has = cs.some((c) => c.id === created.id); return { ...t, comments: has ? cs : [...cs, created] }; - }) - ); + }); + + const updated = + next.find((t) => nid(t.id) === nid(taskId)) ?? null; + setSelectedTask(updated); + + // persist updated snapshot + try { + const snapRaw = localStorage.getItem( + "commitflow_local_snapshot" + ); + const snap = snapRaw ? JSON.parse(snapRaw) : {}; + snap.tasks = next; + localStorage.setItem( + "commitflow_local_snapshot", + JSON.stringify(snap) + ); + } catch (e) { + console.warn("persist updated snapshot failed", e); + } + + return next; + }); } catch (err) { + console.error("create comment failed, enqueueing", err); try { enqueueOp({ op: "create_comment", - payload: { - taskId: latest.id, - author, - body, - attachments, - }, + payload: { taskId, author, body, attachments }, createdAt: new Date().toISOString(), }); - } catch (_) { - console.log("create_comment failed"); + } catch (e) { + console.warn("enqueue create_comment failed", e); } } finally { qcRef.current.invalidateQueries(["tasks", activeProjectId]); diff --git a/frontend/src/components/TaskCard.tsx b/frontend/src/components/TaskCard.tsx index 4665d35..eb76b60 100644 --- a/frontend/src/components/TaskCard.tsx +++ b/frontend/src/components/TaskCard.tsx @@ -144,8 +144,13 @@ export const TaskCard = React.memo( } return () => ro?.disconnect(); - // intentionally not depending on isBeingDragged here to avoid too frequent re-renders - }, [task.id, task.title, (task as any).description]); + // include comments in deps so measurement updates when comments are added/removed + }, [ + task.id, + task.title, + (task as any).description, + JSON.stringify((task as any).comments || []), + ]); const origRectRef = useRef<{ left: number; top: number } | null>(null); useEffect(() => { @@ -546,6 +551,31 @@ export const TaskCard = React.memo( const nextAssignee = (next.task as any).assigneeId ?? (next.task as any).assigneeName ?? null; if (prevAssignee !== nextAssignee) return false; + + // --- comments comparison: length + last createdAt (cheap & robust) --- + const prevComments: any[] = Array.isArray((prev.task as any).comments) + ? (prev.task as any).comments + : []; + const nextComments: any[] = Array.isArray((next.task as any).comments) + ? (next.task as any).comments + : []; + + if (prevComments.length !== nextComments.length) return false; + + const getLastCreatedAt = (arr: any[]) => + arr.length === 0 + ? null + : (arr + .map((c: any) => c?.createdAt ?? c?.created_at ?? null) + .filter(Boolean) + .sort() + .slice(-1)[0] as string | null); + + const prevLast = getLastCreatedAt(prevComments); + const nextLast = getLastCreatedAt(nextComments); + if (String(prevLast) !== String(nextLast)) return false; + // --- end comments comparison --- + if (prev.isBeingDragged !== next.isBeingDragged) return false; if (prev.isBeingDragged) { return ( diff --git a/frontend/src/components/TaskModal.tsx b/frontend/src/components/TaskModal.tsx index f37512c..e182094 100644 --- a/frontend/src/components/TaskModal.tsx +++ b/frontend/src/components/TaskModal.tsx @@ -7,6 +7,7 @@ import { BubblesIcon, Check, File, + Loader2, MessageSquare, Paperclip, Save, @@ -54,6 +55,7 @@ export default function TaskModal({ const [local, setLocal] = useState(task); const [commentText, setCommentText] = useState(""); const [files, setFiles] = useState([]); + const [isLoading, setIsLoading] = useState(false); const [uploading, setUploading] = useState(false); const fileInputRef = useRef(null); const currentAssignee = team.find((t) => t.id === task.assigneeId); @@ -132,9 +134,7 @@ export default function TaskModal({ ); setLocal((s) => ({ - ...s, comments: [ - ...(s.comments || []), { id: Math.random().toString(36).slice(2, 9), author: currentMemberName ?? "No Name", @@ -142,7 +142,9 @@ export default function TaskModal({ createdAt: new Date().toISOString(), attachments, }, + ...(s.comments || []), ], + ...s, })); setCommentText(""); @@ -250,6 +252,136 @@ export default function TaskModal({ } }; + // --- tambahkan bersama function lainnya di komponen (mis. setelah handleAssigneeChange) --- + // props: onAddComment: (author, body, attachments?) => Promise + + async function handleSaveClick() { + setIsLoading(true); + const toSave: Task = { ...local }; + + // normalize simple empty strings -> null like sebelumya + if (!toSave.assigneeId && toSave.assigneeName) { + const member = team.find((m) => m.name === toSave.assigneeName); + if (member) toSave.assigneeId = member.id; + } + if (toSave.assigneeId === "") toSave.assigneeId = null; + if (toSave.priority === "") toSave.priority = null; + if (toSave.startDate === "") toSave.startDate = null; + if (toSave.dueDate === "") toSave.dueDate = null; + + // jika ada comment pending, upload + add comment dulu dan await + if (!isEmptyQuill(commentText) || files.length > 0) { + setUploading(true); + let attachments: Attachment[] | undefined = undefined; + try { + if (files.length > 0) { + const folder = `projects/${local.projectId}/tasks/${local.id}`; + const urls = await uploadMultipleFilesToS3(files, folder); + attachments = urls.map((u, i) => ({ + id: Math.random().toString(36).slice(2, 9), + name: files[i].name, + type: files[i].type, + size: files[i].size, + url: u, + })); + } + + // await parent to persist comment and return the saved comment object (if parent returns one) + let savedComment: any = null; + try { + savedComment = await onAddComment( + currentMemberName ?? "No Name", + commentText.trim(), + attachments + ); + } catch (e) { + // parent might throw or not return; we'll fallback to optimistic tmp comment + console.warn("onAddComment threw or rejected:", e); + savedComment = null; + } + + // use the returned savedComment (server-assigned id / timestamps), fallback to local newComment + const newComment = + savedComment ?? + ({ + id: Math.random().toString(36).slice(2, 9), + // keep taskId if available; parent will normalize if needed + taskId: toSave.id ?? local.id, + author: currentMemberName ?? "No Name", + body: commentText.trim(), + attachments: + attachments && attachments.length > 0 ? attachments : null, + createdAt: new Date().toISOString(), + isTrash: false, + } as any); + + // prepend comment so it shows up immediately + toSave.comments = [newComment, ...(toSave.comments || [])]; + + // reset editor & pending files + setCommentText(""); + setFiles([]); + if (fileInputRef.current) fileInputRef.current.value = ""; + } catch (err: any) { + console.error("[Save] upload/addComment failed:", err); + await Swal.fire({ + title: "Upload/Add comment failed", + text: `Gagal upload atau menambahkan komentar: ${ + err?.message || err + }`, + icon: "error", + background: dark ? "#111827" : undefined, + color: dark ? "#e5e7eb" : undefined, + }); + // stop save to avoid losing comment; keep modal open + setUploading(false); + setIsLoading(false); + return; + } finally { + setUploading(false); + } + } + + // now safe to call onSave (parent will include comments) + try { + await onSave(toSave); + // close modal only after successful save + onClose(); + } catch (err: any) { + console.error("onSave failed", err); + await Swal.fire({ + title: "Save failed", + text: err?.message || String(err), + icon: "error", + background: dark ? "#111827" : undefined, + color: dark ? "#e5e7eb" : undefined, + }); + // do not close modal on failure + } finally { + setIsLoading(false); + } + } + + function isEmptyQuill(html?: string | null) { + if (!html) return true; + + // jika ada treat as non-empty + if (/"); + + // remove all tags and test remaining text + s = s.replace(/<[^>]*>/g, ""); + s = s.replace(/\s+/g, ""); + + return s.length === 0; + } + return (
{/* Backdrop */} @@ -298,33 +430,25 @@ export default function TaskModal({