Merge pull request 'feat(gateway): WorkspaceService + ProjectBootstrapService + TeamsService (P8-015)' (#183) from feat/p8-015-workspaces into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
This commit was merged in pull request #183.
This commit is contained in:
@@ -21,6 +21,7 @@ import { CommandsModule } from './commands/commands.module.js';
|
|||||||
import { PreferencesModule } from './preferences/preferences.module.js';
|
import { PreferencesModule } from './preferences/preferences.module.js';
|
||||||
import { GCModule } from './gc/gc.module.js';
|
import { GCModule } from './gc/gc.module.js';
|
||||||
import { ReloadModule } from './reload/reload.module.js';
|
import { ReloadModule } from './reload/reload.module.js';
|
||||||
|
import { WorkspaceModule } from './workspace/workspace.module.js';
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -46,6 +47,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
|||||||
CommandsModule,
|
CommandsModule,
|
||||||
GCModule,
|
GCModule,
|
||||||
ReloadModule,
|
ReloadModule,
|
||||||
|
WorkspaceModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
63
apps/gateway/src/workspace/project-bootstrap.service.ts
Normal file
63
apps/gateway/src/workspace/project-bootstrap.service.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import type { Brain } from '@mosaic/brain';
|
||||||
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
|
import { WorkspaceService } from './workspace.service.js';
|
||||||
|
|
||||||
|
export interface BootstrapProjectParams {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
userId: string;
|
||||||
|
teamId?: string;
|
||||||
|
repoUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapProjectResult {
|
||||||
|
projectId: string;
|
||||||
|
workspacePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProjectBootstrapService {
|
||||||
|
private readonly logger = new Logger(ProjectBootstrapService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(BRAIN) private readonly brain: Brain,
|
||||||
|
private readonly workspace: WorkspaceService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap a new project: create DB record + workspace directory.
|
||||||
|
* Returns the created project with its workspace path.
|
||||||
|
*/
|
||||||
|
async bootstrap(params: BootstrapProjectParams): Promise<BootstrapProjectResult> {
|
||||||
|
const ownerType: 'user' | 'team' = params.teamId ? 'team' : 'user';
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Bootstrapping project "${params.name}" for ${ownerType} ${params.teamId ?? params.userId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Create DB record
|
||||||
|
const project = await this.brain.projects.create({
|
||||||
|
name: params.name,
|
||||||
|
description: params.description,
|
||||||
|
ownerId: params.userId,
|
||||||
|
teamId: params.teamId ?? null,
|
||||||
|
ownerType,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Create workspace directory
|
||||||
|
const workspacePath = await this.workspace.create(
|
||||||
|
{
|
||||||
|
id: project.id,
|
||||||
|
ownerType,
|
||||||
|
userId: params.userId,
|
||||||
|
teamId: params.teamId ?? null,
|
||||||
|
},
|
||||||
|
params.repoUrl,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Project ${project.id} bootstrapped at ${workspacePath}`);
|
||||||
|
|
||||||
|
return { projectId: project.id, workspacePath };
|
||||||
|
}
|
||||||
|
}
|
||||||
30
apps/gateway/src/workspace/teams.controller.ts
Normal file
30
apps/gateway/src/workspace/teams.controller.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { TeamsService } from './teams.service.js';
|
||||||
|
|
||||||
|
@Controller('api/teams')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class TeamsController {
|
||||||
|
constructor(private readonly teams: TeamsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async list() {
|
||||||
|
return this.teams.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':teamId')
|
||||||
|
async findOne(@Param('teamId') teamId: string) {
|
||||||
|
return this.teams.findById(teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':teamId/members')
|
||||||
|
async listMembers(@Param('teamId') teamId: string) {
|
||||||
|
return this.teams.listMembers(teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':teamId/members/:userId')
|
||||||
|
async checkMembership(@Param('teamId') teamId: string, @Param('userId') userId: string) {
|
||||||
|
const isMember = await this.teams.isMember(teamId, userId);
|
||||||
|
return { isMember };
|
||||||
|
}
|
||||||
|
}
|
||||||
73
apps/gateway/src/workspace/teams.service.ts
Normal file
73
apps/gateway/src/workspace/teams.service.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { eq, and, type Db, teams, teamMembers, projects } from '@mosaic/db';
|
||||||
|
import { DB } from '../database/database.module.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TeamsService {
|
||||||
|
private readonly logger = new Logger(TeamsService.name);
|
||||||
|
|
||||||
|
constructor(@Inject(DB) private readonly db: Db) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user is a member of a team.
|
||||||
|
*/
|
||||||
|
async isMember(teamId: string, userId: string): Promise<boolean> {
|
||||||
|
const rows = await this.db
|
||||||
|
.select({ id: teamMembers.id })
|
||||||
|
.from(teamMembers)
|
||||||
|
.where(and(eq(teamMembers.teamId, teamId), eq(teamMembers.userId, userId)));
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check project access for a user.
|
||||||
|
* - ownerType === 'user': project.ownerId must equal userId
|
||||||
|
* - ownerType === 'team': userId must be a member of project.teamId
|
||||||
|
*/
|
||||||
|
async canAccessProject(userId: string, projectId: string): Promise<boolean> {
|
||||||
|
const rows = await this.db
|
||||||
|
.select({
|
||||||
|
id: projects.id,
|
||||||
|
ownerType: projects.ownerType,
|
||||||
|
ownerId: projects.ownerId,
|
||||||
|
teamId: projects.teamId,
|
||||||
|
})
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, projectId));
|
||||||
|
|
||||||
|
const project = rows[0];
|
||||||
|
if (!project) return false;
|
||||||
|
|
||||||
|
if (project.ownerType === 'user') {
|
||||||
|
return project.ownerId === userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.ownerType === 'team' && project.teamId) {
|
||||||
|
return this.isMember(project.teamId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all teams (for admin/listing endpoints).
|
||||||
|
*/
|
||||||
|
async findAll() {
|
||||||
|
return this.db.select().from(teams);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a team by ID.
|
||||||
|
*/
|
||||||
|
async findById(id: string) {
|
||||||
|
const rows = await this.db.select().from(teams).where(eq(teams.id, id));
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List members of a team.
|
||||||
|
*/
|
||||||
|
async listMembers(teamId: string) {
|
||||||
|
return this.db.select().from(teamMembers).where(eq(teamMembers.teamId, teamId));
|
||||||
|
}
|
||||||
|
}
|
||||||
30
apps/gateway/src/workspace/workspace.controller.ts
Normal file
30
apps/gateway/src/workspace/workspace.controller.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
|
import { ProjectBootstrapService } from './project-bootstrap.service.js';
|
||||||
|
|
||||||
|
@Controller('api/workspaces')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class WorkspaceController {
|
||||||
|
constructor(private readonly bootstrap: ProjectBootstrapService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(
|
||||||
|
@CurrentUser() user: { id: string },
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
teamId?: string;
|
||||||
|
repoUrl?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.bootstrap.bootstrap({
|
||||||
|
name: body.name,
|
||||||
|
description: body.description,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: body.teamId,
|
||||||
|
repoUrl: body.repoUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/gateway/src/workspace/workspace.module.ts
Normal file
13
apps/gateway/src/workspace/workspace.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { WorkspaceService } from './workspace.service.js';
|
||||||
|
import { ProjectBootstrapService } from './project-bootstrap.service.js';
|
||||||
|
import { TeamsService } from './teams.service.js';
|
||||||
|
import { WorkspaceController } from './workspace.controller.js';
|
||||||
|
import { TeamsController } from './teams.controller.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [WorkspaceController, TeamsController],
|
||||||
|
providers: [WorkspaceService, ProjectBootstrapService, TeamsService],
|
||||||
|
exports: [WorkspaceService, ProjectBootstrapService, TeamsService],
|
||||||
|
})
|
||||||
|
export class WorkspaceModule {}
|
||||||
79
apps/gateway/src/workspace/workspace.service.spec.ts
Normal file
79
apps/gateway/src/workspace/workspace.service.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { WorkspaceService } from './workspace.service.js';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
describe('WorkspaceService', () => {
|
||||||
|
let service: WorkspaceService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new WorkspaceService();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolvePath', () => {
|
||||||
|
it('resolves user workspace path', () => {
|
||||||
|
const result = service.resolvePath({
|
||||||
|
id: 'proj1',
|
||||||
|
ownerType: 'user',
|
||||||
|
userId: 'user1',
|
||||||
|
teamId: null,
|
||||||
|
});
|
||||||
|
expect(result).toContain(path.join('users', 'user1', 'proj1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves team workspace path', () => {
|
||||||
|
const result = service.resolvePath({
|
||||||
|
id: 'proj1',
|
||||||
|
ownerType: 'team',
|
||||||
|
userId: 'user1',
|
||||||
|
teamId: 'team1',
|
||||||
|
});
|
||||||
|
expect(result).toContain(path.join('teams', 'team1', 'proj1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to user path when ownerType is team but teamId is null', () => {
|
||||||
|
const result = service.resolvePath({
|
||||||
|
id: 'proj1',
|
||||||
|
ownerType: 'team',
|
||||||
|
userId: 'user1',
|
||||||
|
teamId: null,
|
||||||
|
});
|
||||||
|
expect(result).toContain(path.join('users', 'user1', 'proj1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses MOSAIC_ROOT env var as the base path', () => {
|
||||||
|
const originalRoot = process.env['MOSAIC_ROOT'];
|
||||||
|
process.env['MOSAIC_ROOT'] = '/custom/root';
|
||||||
|
const customService = new WorkspaceService();
|
||||||
|
const result = customService.resolvePath({
|
||||||
|
id: 'proj1',
|
||||||
|
ownerType: 'user',
|
||||||
|
userId: 'user1',
|
||||||
|
teamId: null,
|
||||||
|
});
|
||||||
|
expect(result).toMatch(/^\/custom\/root/);
|
||||||
|
// Restore
|
||||||
|
if (originalRoot === undefined) {
|
||||||
|
delete process.env['MOSAIC_ROOT'];
|
||||||
|
} else {
|
||||||
|
process.env['MOSAIC_ROOT'] = originalRoot;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to /opt/mosaic when MOSAIC_ROOT is unset', () => {
|
||||||
|
const originalRoot = process.env['MOSAIC_ROOT'];
|
||||||
|
delete process.env['MOSAIC_ROOT'];
|
||||||
|
const defaultService = new WorkspaceService();
|
||||||
|
const result = defaultService.resolvePath({
|
||||||
|
id: 'proj2',
|
||||||
|
ownerType: 'user',
|
||||||
|
userId: 'user2',
|
||||||
|
teamId: null,
|
||||||
|
});
|
||||||
|
expect(result).toMatch(/^\/opt\/mosaic/);
|
||||||
|
// Restore
|
||||||
|
if (originalRoot !== undefined) {
|
||||||
|
process.env['MOSAIC_ROOT'] = originalRoot;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
111
apps/gateway/src/workspace/workspace.service.ts
Normal file
111
apps/gateway/src/workspace/workspace.service.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
export interface WorkspaceProject {
|
||||||
|
id: string;
|
||||||
|
ownerType: 'user' | 'team';
|
||||||
|
userId: string;
|
||||||
|
teamId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WorkspaceService {
|
||||||
|
private readonly logger = new Logger(WorkspaceService.name);
|
||||||
|
private readonly mosaicRoot: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.mosaicRoot = process.env['MOSAIC_ROOT'] ?? '/opt/mosaic';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the workspace path for a project.
|
||||||
|
* Solo: $MOSAIC_ROOT/.workspaces/users/<userId>/<projectId>/
|
||||||
|
* Team: $MOSAIC_ROOT/.workspaces/teams/<teamId>/<projectId>/
|
||||||
|
*/
|
||||||
|
resolvePath(project: WorkspaceProject): string {
|
||||||
|
if (project.ownerType === 'team' && project.teamId) {
|
||||||
|
return path.join(this.mosaicRoot, '.workspaces', 'teams', project.teamId, project.id);
|
||||||
|
}
|
||||||
|
return path.join(this.mosaicRoot, '.workspaces', 'users', project.userId, project.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a workspace directory and initialize it as a git repo.
|
||||||
|
* If repoUrl is provided, clone instead of init.
|
||||||
|
*/
|
||||||
|
async create(project: WorkspaceProject, repoUrl?: string): Promise<string> {
|
||||||
|
const workspacePath = this.resolvePath(project);
|
||||||
|
|
||||||
|
// Create directory
|
||||||
|
await fs.mkdir(workspacePath, { recursive: true });
|
||||||
|
|
||||||
|
if (repoUrl) {
|
||||||
|
// Clone existing repo
|
||||||
|
await execFileAsync('git', ['clone', repoUrl, '.'], { cwd: workspacePath });
|
||||||
|
this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`);
|
||||||
|
} else {
|
||||||
|
// Init new git repo
|
||||||
|
await execFileAsync('git', ['init'], { cwd: workspacePath });
|
||||||
|
await execFileAsync('git', ['commit', '--allow-empty', '-m', 'Initial workspace commit'], {
|
||||||
|
cwd: workspacePath,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GIT_AUTHOR_NAME: 'Mosaic',
|
||||||
|
GIT_AUTHOR_EMAIL: 'mosaic@localhost',
|
||||||
|
GIT_COMMITTER_NAME: 'Mosaic',
|
||||||
|
GIT_COMMITTER_EMAIL: 'mosaic@localhost',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.log(`Initialized git workspace at ${workspacePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return workspacePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a workspace directory recursively.
|
||||||
|
*/
|
||||||
|
async delete(project: WorkspaceProject): Promise<void> {
|
||||||
|
const workspacePath = this.resolvePath(project);
|
||||||
|
try {
|
||||||
|
await fs.rm(workspacePath, { recursive: true, force: true });
|
||||||
|
this.logger.log(`Deleted workspace at ${workspacePath}`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to delete workspace at ${workspacePath}: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the workspace directory exists.
|
||||||
|
*/
|
||||||
|
async exists(project: WorkspaceProject): Promise<boolean> {
|
||||||
|
const workspacePath = this.resolvePath(project);
|
||||||
|
try {
|
||||||
|
await fs.access(workspacePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the base user workspace directory (call on user registration).
|
||||||
|
*/
|
||||||
|
async createUserRoot(userId: string): Promise<void> {
|
||||||
|
const userRoot = path.join(this.mosaicRoot, '.workspaces', 'users', userId);
|
||||||
|
await fs.mkdir(userRoot, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the base team workspace directory (call on team creation).
|
||||||
|
*/
|
||||||
|
async createTeamRoot(teamId: string): Promise<void> {
|
||||||
|
const teamRoot = path.join(this.mosaicRoot, '.workspaces', 'teams', teamId);
|
||||||
|
await fs.mkdir(teamRoot, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user