Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
-- CreateTable
CREATE TABLE "ProjectChatMessage" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"projectId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,

CONSTRAINT "ProjectChatMessage_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "ProjectChatMention" (
"id" TEXT NOT NULL,
"messageId" TEXT NOT NULL,
"userId" TEXT NOT NULL,

CONSTRAINT "ProjectChatMention_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "ProjectChatMessage_projectId_createdAt_idx" ON "ProjectChatMessage"("projectId", "createdAt");

-- CreateIndex
CREATE UNIQUE INDEX "ProjectChatMention_messageId_userId_key" ON "ProjectChatMention"("messageId", "userId");

-- CreateIndex
CREATE INDEX "ProjectChatMention_userId_idx" ON "ProjectChatMention"("userId");

-- AddForeignKey
ALTER TABLE "ProjectChatMessage" ADD CONSTRAINT "ProjectChatMessage_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ProjectChatMessage" ADD CONSTRAINT "ProjectChatMessage_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ProjectChatMention" ADD CONSTRAINT "ProjectChatMention_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "ProjectChatMessage"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ProjectChatMention" ADD CONSTRAINT "ProjectChatMention_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
47 changes: 40 additions & 7 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ model User {
providerId String
createdAt DateTime @default(now())

ownedTeams Team[] @relation("TeamOwner")
memberships TeamMember[]
assignedTasks Task[] @relation("TaskAssignee")
createdTasks Task[] @relation("TaskCreator")
ownedTeams Team[] @relation("TeamOwner")
memberships TeamMember[]
assignedTasks Task[] @relation("TaskAssignee")
createdTasks Task[] @relation("TaskCreator")
projectMessages ProjectChatMessage[] @relation("ProjectChatAuthor")
chatMentions ProjectChatMention[] @relation("ProjectChatMentionedUser")

@@unique([provider, providerId])
}
Expand Down Expand Up @@ -63,9 +65,10 @@ model Project {
status ProjectStatus @default(ACTIVE)
createdAt DateTime @default(now())

teamId String
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
tasks Task[]
teamId String
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
tasks Task[]
chatMessages ProjectChatMessage[]
}

enum ProjectStatus {
Expand Down Expand Up @@ -94,6 +97,36 @@ model Task {
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
}

model ProjectChatMessage {
id String @id @default(cuid())
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)

authorId String
author User @relation("ProjectChatAuthor", fields: [authorId], references: [id], onDelete: Cascade)

mentions ProjectChatMention[]

@@index([projectId, createdAt])
}

model ProjectChatMention {
id String @id @default(cuid())

messageId String
message ProjectChatMessage @relation(fields: [messageId], references: [id], onDelete: Cascade)

userId String
user User @relation("ProjectChatMentionedUser", fields: [userId], references: [id], onDelete: Cascade)

@@unique([messageId, userId])
@@index([userId])
}

enum TaskStatus {
TODO
IN_PROGRESS
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AuthModule } from "./auth/auth.module";
import { JwtAuthGuard } from "./auth/guards/jwt-auth.guard";
import { MailModule } from "./mail/mail.module";
import { PrismaModule } from "./prisma/prisma.module";
import { ProjectChatModule } from "./project-chat/project-chat.module";
import { ProjectsModule } from "./projects/projects.module";
import { TasksModule } from "./tasks/tasks.module";
import { TeamsModule } from "./teams/teams.module";
Expand All @@ -17,6 +18,7 @@ import { UsersModule } from "./users/users.module";
AuthModule,
TeamsModule,
ProjectsModule,
ProjectChatModule,
TasksModule,
UsersModule,
],
Expand Down
14 changes: 14 additions & 0 deletions apps/api/src/project-chat/dto/create-project-chat-message.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ArrayMaxSize, IsArray, IsOptional, IsString, MaxLength, MinLength } from "class-validator";

export class CreateProjectChatMessageDto {
@IsString()
@MinLength(1)
@MaxLength(2000)
content!: string;

@IsOptional()
@IsArray()
@ArrayMaxSize(20)
@IsString({ each: true })
mentionUserIds?: string[];
}
29 changes: 29 additions & 0 deletions apps/api/src/project-chat/project-chat.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Body, Controller, Get, Param, Post, UseGuards } from "@nestjs/common";
import type { ProjectChatMessage } from "@repo/types";

import { CurrentUser } from "../auth/decorators/current-user.decorator";
import type { AuthUser } from "../auth/interfaces/auth-user.interface";
import { ProjectMemberGuard } from "../tasks/guards/project-member.guard";

import { CreateProjectChatMessageDto } from "./dto/create-project-chat-message.dto";
import { ProjectChatService } from "./project-chat.service";

@UseGuards(ProjectMemberGuard)
@Controller("projects/:projectId/chat/messages")
export class ProjectChatController {
constructor(private readonly projectChatService: ProjectChatService) {}

@Get()
listMessages(@Param("projectId") projectId: string): Promise<ProjectChatMessage[]> {
return this.projectChatService.listMessages(projectId);
}

@Post()
createMessage(
@Param("projectId") projectId: string,
@Body() dto: CreateProjectChatMessageDto,
@CurrentUser() user: AuthUser,
): Promise<ProjectChatMessage> {
return this.projectChatService.createMessage(projectId, dto, user);
}
}
15 changes: 15 additions & 0 deletions apps/api/src/project-chat/project-chat.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Module } from "@nestjs/common";

import { PrismaModule } from "../prisma/prisma.module";
import { ProjectMemberGuard } from "../tasks/guards/project-member.guard";

import { ProjectChatController } from "./project-chat.controller";
import { ProjectChatService } from "./project-chat.service";

@Module({
imports: [PrismaModule],
controllers: [ProjectChatController],
providers: [ProjectChatService, ProjectMemberGuard],
exports: [ProjectChatService],
})
export class ProjectChatModule {}
123 changes: 123 additions & 0 deletions apps/api/src/project-chat/project-chat.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { NotFoundException } from "@nestjs/common";

import { ProjectChatService } from "./project-chat.service";

describe("ProjectChatService", () => {
it("creates a message and resolves mentions from content and explicit ids", async () => {
const create = jest.fn().mockResolvedValue({ id: "msg_1" });
const service = new ProjectChatService({
project: {
findUnique: jest.fn().mockResolvedValue({ teamId: "team_1" }),
},
teamMember: {
findMany: jest.fn().mockResolvedValue([
{
userId: "user_1",
user: {
id: "user_1",
email: "owner@example.com",
name: "Owner",
avatarUrl: null,
},
},
{
userId: "user_2",
user: {
id: "user_2",
email: "alice@example.com",
name: "Alice Johnson",
avatarUrl: null,
},
},
]),
},
projectChatMessage: { create },
} as never);

await service.createMessage(
"project_1",
{
content: "Hello @alice and @alice.johnson",
mentionUserIds: ["user_2"],
},
{
sub: "user_1",
email: "owner@example.com",
provider: "github",
},
);

expect(create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
projectId: "project_1",
authorId: "user_1",
content: "Hello @alice and @alice.johnson",
mentions: {
create: [{ userId: "user_2" }],
},
}),
}),
);
});

it("does not create mentions for users outside project team", async () => {
const create = jest.fn().mockResolvedValue({ id: "msg_1" });
const service = new ProjectChatService({
project: {
findUnique: jest.fn().mockResolvedValue({ teamId: "team_1" }),
},
teamMember: {
findMany: jest.fn().mockResolvedValue([
{
userId: "user_1",
user: {
id: "user_1",
email: "owner@example.com",
name: "Owner",
avatarUrl: null,
},
},
]),
},
projectChatMessage: { create },
} as never);

await service.createMessage(
"project_1",
{
content: "Hey @external",
mentionUserIds: ["user_999"],
},
{
sub: "user_1",
email: "owner@example.com",
provider: "github",
},
);

expect(create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
mentions: undefined,
}),
}),
);
});

it("throws when project does not exist", async () => {
const service = new ProjectChatService({
project: {
findUnique: jest.fn().mockResolvedValue(null),
},
} as never);

await expect(
service.createMessage(
"missing_project",
{ content: "Message" },
{ sub: "user_1", email: "user@example.com", provider: "github" },
),
).rejects.toBeInstanceOf(NotFoundException);
});
});
Loading