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
21 changes: 21 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@prisma/client": "^5.16.1",
Expand Down
31 changes: 31 additions & 0 deletions backend/src/activity/activity.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// backend/src/activity/activity.controller.ts
import { Controller, Get, Param } from '@nestjs/common';
import { ActivityService } from './activity.service';
import { CurrentUser } from '../auth/current-user.decorator';
import type { AuthUser } from '../auth/auth-user.interface';

@Controller('activity')
export class ActivityController {
constructor(private readonly activityService: ActivityService) {}

@Get('workspace/:workspaceId')
async getWorkspaceActivity(
@CurrentUser() user: AuthUser,
@Param('workspaceId') workspaceId: string,
) {
const activity = await this.activityService.listForWorkspace(
user,
workspaceId,
);
return { activity };
}

@Get('project/:projectId')
async getProjectActivity(
@CurrentUser() user: AuthUser,
@Param('projectId') projectId: string,
) {
const activity = await this.activityService.listForProject(user, projectId);
return { activity };
}
}
13 changes: 13 additions & 0 deletions backend/src/activity/activity.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// backend/src/activity/activity.module.ts
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { ActivityService } from './activity.service';
import { ActivityController } from './activity.controller';

@Module({
imports: [PrismaModule],
controllers: [ActivityController],
providers: [ActivityService],
exports: [ActivityService],
})
export class ActivityModule {}
101 changes: 101 additions & 0 deletions backend/src/activity/activity.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// backend/src/activity/activity.service.ts
import {
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import type { AuthUser } from '../auth/auth-user.interface';

@Injectable()
export class ActivityService {
constructor(private readonly prisma: PrismaService) {}

private async ensureUser(authUser: AuthUser) {
const auth0Id = authUser.auth0Id || (authUser as any).sub;
if (!auth0Id) {
throw new UnauthorizedException('Missing Auth0 user id');
}

let user = await this.prisma.user.findUnique({
where: { auth0Id },
});

if (!user) {
user = await this.prisma.user.create({
data: {
auth0Id,
email: authUser.email,
name: authUser.name,
picture: authUser.picture,
},
});
}

return user;
}

private async assertWorkspaceMembership(workspaceId: string, userId: string) {
const membership = await this.prisma.workspaceMember.findFirst({
where: { workspaceId, userId },
});

if (!membership) {
throw new ForbiddenException('You are not a member of this workspace');
}
}

async listForWorkspace(authUser: AuthUser, workspaceId: string) {
const user = await this.ensureUser(authUser);
await this.assertWorkspaceMembership(workspaceId, user.id);

const activity = await this.prisma.activityLog.findMany({
where: { workspaceId },
orderBy: { createdAt: 'desc' },
take: 20,
include: {
project: { select: { id: true, name: true } },
task: { select: { id: true, title: true } },
user: { select: { id: true, name: true, email: true } },
},
});

return activity;
}

async listForProject(authUser: AuthUser, projectId: string) {
const user = await this.ensureUser(authUser);

// ensure user has access to this project via workspace membership
const project = await this.prisma.project.findFirst({
where: {
id: projectId,
workspace: {
members: {
some: { userId: user.id },
},
},
},
include: {
workspace: true,
},
});

if (!project) {
throw new ForbiddenException('You do not have access to this project');
}

const activity = await this.prisma.activityLog.findMany({
where: { projectId },
orderBy: { createdAt: 'desc' },
take: 20,
include: {
project: { select: { id: true, name: true } },
task: { select: { id: true, title: true } },
user: { select: { id: true, name: true, email: true } },
},
});

return activity;
}
}
12 changes: 6 additions & 6 deletions backend/src/app.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ export class AppController {
}

// RBAC-protected route
@UseGuards(JwtAuthGuard, PermissionsGuard)
@Permissions('read:projects')
@Get('projects')
listProjects() {
return [{ id: 1, name: 'Sample' }];
}
// @UseGuards(JwtAuthGuard, PermissionsGuard)
// @Permissions('read:projects')
// @Get('projects')
// listProjects() {
// return [{ id: 1, name: 'Sample' }];
// }
}
4 changes: 4 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { JwtAuthGuard } from './auth/jwt.guard';
import { PrismaModule } from '../prisma/prisma.module';
import { WorkspacesModule } from './workspaces/workspaces.module';
import { ProjectsModule } from './projects/projects.module';
import { TasksModule } from './tasks/tasks.module';
import { ActivityModule } from './activity/activity.module';

@Module({
imports: [
Expand All @@ -17,6 +19,8 @@ import { ProjectsModule } from './projects/projects.module';
PrismaModule,
WorkspacesModule,
ProjectsModule,
TasksModule,
ActivityModule,
],
controllers: [AppController],
providers: [
Expand Down
2 changes: 1 addition & 1 deletion backend/src/auth/auth-user.interface.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export interface AuthUser {
auth0Id: string;
email?: string;
email: string;
name?: string;
picture?: string;
permissions: string[];
Expand Down
21 changes: 21 additions & 0 deletions backend/src/projects/dto/create-project.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { IsBoolean, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';

export class CreateProjectDto {
@IsString()
@IsNotEmpty()
workspaceId: string;

@IsString()
@IsNotEmpty()
@MaxLength(120)
name: string;

@IsString()
@IsOptional()
@MaxLength(1000)
description?: string;

@IsBoolean()
@IsOptional()
archived?: boolean;
}
19 changes: 19 additions & 0 deletions backend/src/projects/dto/update-project.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateProjectDto } from './create-project.dto';
import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator';

export class UpdateProjectDto extends PartialType(CreateProjectDto) {
@IsString()
@IsOptional()
@MaxLength(120)
name?: string;

@IsString()
@IsOptional()
@MaxLength(1000)
description?: string;

@IsBoolean()
@IsOptional()
archived?: boolean;
}
90 changes: 58 additions & 32 deletions backend/src/projects/projects.controller.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,39 @@
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { ProjectsService } from './projects.service';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectDto } from './dto/update-project.dto';
import { CurrentUser } from '../auth/current-user.decorator';
import type { AuthUser } from '../auth/auth-user.interface';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateProjectDto } from '../workspaces/dto/create-project.dto';
import { WorkspacesService } from '../workspaces/workspaces.service';

@Controller('projects')
export class ProjectsController {
constructor(
private readonly projectsService: ProjectsService,
private readonly prisma: PrismaService,
private readonly workspacesService: WorkspacesService,
) {}

/**
* Helper: ensure we have a local User row for this Auth0 user,
* and return the internal user.id used by Prisma relations.
*/
private async getOrCreateUserId(userPayload: AuthUser) {
const auth0Id = userPayload.auth0Id || (userPayload as any).sub;
const email = userPayload.email;
const name = userPayload.name;
const picture = userPayload.picture;

const user = await this.workspacesService.ensureUser(auth0Id, email, name, picture);
return user.id;
}

@Get()
async getProjects(
async listProjects(
@CurrentUser() user: AuthUser,
@Query('workspaceId') workspaceId: string,
) {
if (!workspaceId) {
return { projects: [] };
}

const internalUser = await this.prisma.user.findUnique({
where: { auth0Id: user.auth0Id },
});

if (!internalUser) {
return { projects: [] };
}

const projects = await this.projectsService.listForWorkspace(
internalUser.id,
workspaceId,
);
const userId = await this.getOrCreateUserId(user);
const projects = await this.projectsService.listByWorkspace(workspaceId, userId);

return { projects };
}
Expand All @@ -42,19 +43,44 @@ export class ProjectsController {
@CurrentUser() user: AuthUser,
@Body() dto: CreateProjectDto,
) {
const internalUser = await this.prisma.user.findUnique({
where: { auth0Id: user.auth0Id },
});
const userId = await this.getOrCreateUserId(user);
const project = await this.projectsService.createForWorkspace(dto, userId);

return { project };
}

@Get(':id')
async getProject(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
) {
const userId = await this.getOrCreateUserId(user);
const project = await this.projectsService.findOne(id, userId);

return { project };
}

if (!internalUser) {
throw new Error('User not found in local database');
}
@Patch(':id')
async updateProject(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() dto: UpdateProjectDto,
) {
const userId = await this.getOrCreateUserId(user);
const project = await this.projectsService.update(id, dto, userId);

const project = await this.projectsService.createForWorkspace(
internalUser.id,
dto,
);
return { project };
}

// Soft delete
@Patch(':id/archive')
async archiveProject(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
) {
const userId = await this.getOrCreateUserId(user);
const project = await this.projectsService.remove(id, userId);

return { project };
}
}
}
Loading