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
57 changes: 49 additions & 8 deletions apps/api/src/auth/session.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
import { randomBytes } from 'crypto';
import Redis from 'ioredis';
import { PrismaService } from '../database/prisma.service';
import { User } from '@repo/db';
import type { Prisma, User } from '@repo/db';

const SESSION_PREFIX = 'session:';
const SESSION_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
Expand All @@ -24,29 +24,70 @@ export class SessionService {
/**
* Creates a new session in both DB and Redis
*/
async createSession(userId: string): Promise<string> {
async createSession(
userId: string,
tx?: Prisma.TransactionClient,
): Promise<string> {
const sessionId = this.generateSessionId();
const expiresAt = new Date(Date.now() + SESSION_TTL_SECONDS * 1000);
const prisma = tx ?? this.prisma;

// Store in database (source of truth)
await this.prisma.session.create({
await prisma.session.create({
Comment thread
coderabbitai[bot] marked this conversation as resolved.
data: {
id: sessionId,
userId,
expiresAt,
},
});

if (tx) {
return sessionId;
}

// Cache in Redis with TTL
const sessionData: SessionData = { userId, expiresAt };
await this.cacheSessionData(sessionId, { userId, expiresAt });

this.logger.log(`Session created for user ${userId}`);
return sessionId;
}

/**
* Caches a committed database session in Redis.
*/
async cacheSession(sessionId: string): Promise<void> {
const session = await this.prisma.session.findUnique({
where: { id: sessionId },
select: { userId: true, expiresAt: true },
});

if (!session || session.expiresAt < new Date()) {
return;
}

await this.cacheSessionData(sessionId, session);
}

/**
* Writes session data to Redis using the remaining database TTL.
*/
private async cacheSessionData(
sessionId: string,
sessionData: SessionData,
): Promise<void> {
const ttlSeconds = Math.floor(
(sessionData.expiresAt.getTime() - Date.now()) / 1000,
);

if (ttlSeconds <= 0) {
return;
}

await this.redis.setex(
`${SESSION_PREFIX}${sessionId}`,
SESSION_TTL_SECONDS,
ttlSeconds,
JSON.stringify(sessionData),
);

this.logger.log(`Session created for user ${userId}`);
return sessionId;
}

/**
Expand Down
164 changes: 162 additions & 2 deletions apps/api/src/members/members.controller.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,127 @@
import { Controller, Get, Query } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
Res,
} from '@nestjs/common';
import type { Response } from 'express';
import { MembersService } from './members.service';
import { Roles, CurrentUser } from '../auth/decorators';
import { Roles, CurrentUser, Public } from '../auth/decorators';
import { SESSION_COOKIE_NAME } from '../auth/guards/auth.guard';
import { ZodValidationPipe } from '../common/pipes';
import type { User } from '@repo/db';
import {
memberCreateRequestSchema,
memberInvitationAcceptRequestSchema,
memberInviteRequestSchema,
memberListQuerySchema,
memberUpdateRequestSchema,
type MemberActionResponse,
type MemberCreateRequest,
type MemberInvitationAcceptRequest,
type MemberInvitationAcceptResponse,
type MemberInvitationActionResponse,
type MemberInvitationListResponse,
type MemberInviteRequest,
type MemberListQuery,
type MemberListResponse,
type MemberUpdateRequest,
} from '@repo/contracts';

const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;

@Controller('members')
export class MembersController {
constructor(private readonly membersService: MembersService) {}

/**
* Lists pending member invitations visible to the current admin.
*/
@Get('invitations')
@Roles('SUPER_ADMIN', 'ORG_ADMIN')
async findInvitations(
@Query(new ZodValidationPipe<MemberListQuery>(memberListQuerySchema))
query: MemberListQuery,
@CurrentUser() user: User,
): Promise<MemberInvitationListResponse> {
return this.membersService.findInvitations(query, user);
}

/**
* Accepts a public member invitation and sets the session cookie.
*/
@Post('invitations/accept')
@Public()
@HttpCode(HttpStatus.OK)
async acceptInvitation(
@Body(
new ZodValidationPipe<MemberInvitationAcceptRequest>(
memberInvitationAcceptRequestSchema,
),
)
body: MemberInvitationAcceptRequest,
@Res({ passthrough: true }) response: Response,
): Promise<MemberInvitationAcceptResponse> {
const { sessionId, user } = await this.membersService.acceptInvitation(
body.token,
);

response.cookie(SESSION_COOKIE_NAME, sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: SESSION_MAX_AGE_MS,
path: '/',
});

return { user };
}

/**
* Sends a new member invitation for the current organization scope.
*/
@Post('invitations')
@Roles('SUPER_ADMIN', 'ORG_ADMIN')
async invite(
@Body(new ZodValidationPipe<MemberInviteRequest>(memberInviteRequestSchema))
body: MemberInviteRequest,
@CurrentUser() user: User,
): Promise<MemberInvitationActionResponse> {
return this.membersService.invite(body, user);
}

/**
* Issues a fresh token and email for an existing pending invitation.
*/
@Post('invitations/:id/resend')
@Roles('SUPER_ADMIN', 'ORG_ADMIN')
async resendInvitation(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: User,
): Promise<MemberInvitationActionResponse> {
return this.membersService.resendInvitation(id, user);
}

/**
* Revokes a pending member invitation in the current organization scope.
*/
@Delete('invitations/:id')
@Roles('SUPER_ADMIN', 'ORG_ADMIN')
async revokeInvitation(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: User,
): Promise<MemberInvitationActionResponse> {
return this.membersService.revokeInvitation(id, user);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@Get()
@Roles('SUPER_ADMIN', 'ORG_ADMIN')
async findAll(
Expand All @@ -22,4 +131,55 @@ export class MembersController {
): Promise<MemberListResponse> {
return this.membersService.findAll(query, user);
}

/**
* Creates a Coordly member without creating an auth user.
*/
@Post()
@Roles('SUPER_ADMIN', 'ORG_ADMIN')
async create(
@Body(new ZodValidationPipe<MemberCreateRequest>(memberCreateRequestSchema))
body: MemberCreateRequest,
@CurrentUser() user: User,
): Promise<MemberActionResponse> {
return this.membersService.create(body, user);
}

/**
* Finds one Coordly member in the current organization scope.
*/
@Get(':id')
@Roles('SUPER_ADMIN', 'ORG_ADMIN')
async findOne(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: User,
): Promise<MemberActionResponse> {
return this.membersService.findOne(id, user);
}

/**
* Updates a Coordly member in the current organization scope.
*/
@Patch(':id')
@Roles('SUPER_ADMIN', 'ORG_ADMIN')
async update(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe<MemberUpdateRequest>(memberUpdateRequestSchema))
body: MemberUpdateRequest,
@CurrentUser() user: User,
): Promise<MemberActionResponse> {
return this.membersService.update(id, body, user);
}

/**
* Deletes a Coordly member in the current organization scope.
*/
@Delete(':id')
@Roles('SUPER_ADMIN', 'ORG_ADMIN')
async remove(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: User,
): Promise<MemberActionResponse> {
return this.membersService.remove(id, user);
}
}
3 changes: 3 additions & 0 deletions apps/api/src/members/members.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { Module } from '@nestjs/common';
import { MembersService } from './members.service';
import { MembersController } from './members.controller';
import { AuthModule } from '../auth/auth.module';
import { MailModule } from '../mail/mail.module';

@Module({
imports: [AuthModule, MailModule],
providers: [MembersService],
controllers: [MembersController],
})
Expand Down
Loading