missions page 2
This commit is contained in:
parent
1e0f4092bb
commit
27f5b7dd2f
@ -4,6 +4,7 @@ import { authOptions } from "@/app/api/auth/options";
|
|||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { getPublicUrl } from '@/lib/s3';
|
import { getPublicUrl } from '@/lib/s3';
|
||||||
import { S3_CONFIG } from '@/lib/s3';
|
import { S3_CONFIG } from '@/lib/s3';
|
||||||
|
import { IntegrationService } from '@/lib/services/integration-service';
|
||||||
|
|
||||||
// Helper function to check authentication
|
// Helper function to check authentication
|
||||||
async function checkAuth(request: Request) {
|
async function checkAuth(request: Request) {
|
||||||
@ -44,7 +45,7 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get missions with basic info
|
// Get missions with basic info
|
||||||
const missions = await prisma.mission.findMany({
|
const missions = await (prisma as any).mission.findMany({
|
||||||
where,
|
where,
|
||||||
skip: offset,
|
skip: offset,
|
||||||
take: limit,
|
take: limit,
|
||||||
@ -83,10 +84,10 @@ export async function GET(request: Request) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get total count
|
// Get total count
|
||||||
const totalCount = await prisma.mission.count({ where });
|
const totalCount = await (prisma as any).mission.count({ where });
|
||||||
|
|
||||||
// Transform logo paths to public URLs
|
// Transform logo paths to public URLs
|
||||||
const missionsWithPublicUrls = missions.map(mission => ({
|
const missionsWithPublicUrls = missions.map((mission: any) => ({
|
||||||
...mission,
|
...mission,
|
||||||
logo: mission.logo ? `/api/missions/image/${mission.logo}` : null
|
logo: mission.logo ? `/api/missions/image/${mission.logo}` : null
|
||||||
}));
|
}));
|
||||||
@ -157,8 +158,10 @@ export async function POST(request: Request) {
|
|||||||
}, { status: 400 });
|
}, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wrap the mission creation and integration in a transaction
|
||||||
|
const result = await prisma.$transaction(async (tx: any) => {
|
||||||
// Create the mission
|
// Create the mission
|
||||||
const mission = await prisma.mission.create({
|
const mission = await tx.mission.create({
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
logo,
|
logo,
|
||||||
@ -187,7 +190,7 @@ export async function POST(request: Request) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
if (guardianEntries.length > 0) {
|
if (guardianEntries.length > 0) {
|
||||||
await prisma.missionUser.createMany({
|
await tx.missionUser.createMany({
|
||||||
data: guardianEntries
|
data: guardianEntries
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -201,19 +204,49 @@ export async function POST(request: Request) {
|
|||||||
missionId: mission.id
|
missionId: mission.id
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await prisma.missionUser.createMany({
|
await tx.missionUser.createMany({
|
||||||
data: volunteerEntries
|
data: volunteerEntries
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return mission;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Initialize external integrations after transaction completes
|
||||||
|
const integrationService = new IntegrationService();
|
||||||
|
const integrationResult = await integrationService.setupIntegrationsForMission(result.id);
|
||||||
|
|
||||||
|
if (!integrationResult.success) {
|
||||||
|
// If integration failed, the mission was already deleted in the integration service
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Failed to set up external services',
|
||||||
|
details: integrationResult.error
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
mission: {
|
mission: {
|
||||||
id: mission.id,
|
id: result.id,
|
||||||
name: mission.name,
|
name: result.name,
|
||||||
createdAt: mission.createdAt
|
createdAt: result.createdAt
|
||||||
|
},
|
||||||
|
integrations: {
|
||||||
|
status: 'success',
|
||||||
|
data: integrationResult.data
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} catch (integrationError) {
|
||||||
|
// If there's any unhandled error, delete the mission and report failure
|
||||||
|
console.error('Integration error:', integrationError);
|
||||||
|
await (prisma as any).mission.delete({ where: { id: result.id } });
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Failed to set up external services',
|
||||||
|
details: integrationError instanceof Error ? integrationError.message : String(integrationError)
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating mission:', error);
|
console.error('Error creating mission:', error);
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
157
lib/services/integration-service.ts
Normal file
157
lib/services/integration-service.ts
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { LeantimeService } from './leantime-service';
|
||||||
|
import { OutlineService } from './outline-service';
|
||||||
|
import { RocketChatService } from './rocketchat-service';
|
||||||
|
|
||||||
|
interface IntegrationResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: {
|
||||||
|
leantimeProjectId?: number;
|
||||||
|
outlineCollectionId?: string;
|
||||||
|
rocketChatChannelId?: string;
|
||||||
|
giteaRepositoryUrl?: string;
|
||||||
|
penpotProjectId?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IntegrationService {
|
||||||
|
private leantimeService: LeantimeService;
|
||||||
|
private outlineService: OutlineService;
|
||||||
|
private rocketChatService: RocketChatService;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.leantimeService = new LeantimeService();
|
||||||
|
this.outlineService = new OutlineService();
|
||||||
|
this.rocketChatService = new RocketChatService();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up all integrations for a mission
|
||||||
|
* @param missionId The mission ID
|
||||||
|
* @returns Integration result
|
||||||
|
*/
|
||||||
|
async setupIntegrationsForMission(missionId: string): Promise<IntegrationResult> {
|
||||||
|
try {
|
||||||
|
// Get complete mission data with users
|
||||||
|
const mission = await prisma.mission.findUnique({
|
||||||
|
where: { id: missionId },
|
||||||
|
include: {
|
||||||
|
missionUsers: {
|
||||||
|
include: {
|
||||||
|
user: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
attachments: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mission) {
|
||||||
|
throw new Error(`Mission not found: ${missionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// These fields will store the IDs of created resources
|
||||||
|
let leantimeProjectId: number | undefined;
|
||||||
|
let outlineCollectionId: string | undefined;
|
||||||
|
let rocketChatChannelId: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Create Leantime project
|
||||||
|
leantimeProjectId = await this.leantimeService.createProject(mission);
|
||||||
|
console.log(`Leantime project created with ID: ${leantimeProjectId}`);
|
||||||
|
|
||||||
|
// Step 2: Create Outline collection
|
||||||
|
outlineCollectionId = await this.outlineService.createCollection(mission);
|
||||||
|
console.log(`Outline collection created with ID: ${outlineCollectionId}`);
|
||||||
|
|
||||||
|
// Step 3: Create Rocket.Chat channel
|
||||||
|
rocketChatChannelId = await this.rocketChatService.createChannel(mission);
|
||||||
|
console.log(`Rocket.Chat channel created with ID: ${rocketChatChannelId}`);
|
||||||
|
|
||||||
|
// Add integrations for specific services
|
||||||
|
if (mission.services.includes('gite')) {
|
||||||
|
// TODO: Add Gitea integration when API docs are available
|
||||||
|
console.log('Gitea service requested but integration not implemented yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mission.services.includes('artlab')) {
|
||||||
|
// TODO: Add Penpot integration when API docs are available
|
||||||
|
console.log('Artlab service requested but integration not implemented yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the mission with the integration IDs
|
||||||
|
await prisma.mission.update({
|
||||||
|
where: { id: missionId },
|
||||||
|
data: {
|
||||||
|
leantimeProjectId: leantimeProjectId.toString(),
|
||||||
|
outlineCollectionId: outlineCollectionId,
|
||||||
|
rocketChatChannelId: rocketChatChannelId,
|
||||||
|
// giteaRepositoryUrl and penpotProjectId will be added when implemented
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
leantimeProjectId,
|
||||||
|
outlineCollectionId,
|
||||||
|
rocketChatChannelId
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting up integrations:', error);
|
||||||
|
|
||||||
|
// Rollback any created resources
|
||||||
|
await this.rollbackIntegrations(leantimeProjectId, outlineCollectionId, rocketChatChannelId);
|
||||||
|
|
||||||
|
// Delete the mission itself since an integration failed
|
||||||
|
await prisma.mission.delete({
|
||||||
|
where: { id: missionId }
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Integration failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in integration setup:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Integration setup failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rollback integrations if any step fails
|
||||||
|
* @param leantimeProjectId The Leantime project ID to delete
|
||||||
|
* @param outlineCollectionId The Outline collection ID to delete
|
||||||
|
* @param rocketChatChannelId The Rocket.Chat channel ID to delete
|
||||||
|
*/
|
||||||
|
private async rollbackIntegrations(
|
||||||
|
leantimeProjectId?: number,
|
||||||
|
outlineCollectionId?: string,
|
||||||
|
rocketChatChannelId?: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Attempt to delete Leantime project
|
||||||
|
if (leantimeProjectId) {
|
||||||
|
await this.leantimeService.deleteProject(leantimeProjectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to delete Outline collection
|
||||||
|
if (outlineCollectionId) {
|
||||||
|
await this.outlineService.deleteCollection(outlineCollectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to delete Rocket.Chat channel
|
||||||
|
if (rocketChatChannelId) {
|
||||||
|
await this.rocketChatService.deleteChannel(rocketChatChannelId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during rollback:', error);
|
||||||
|
// Even if rollback fails, we continue with mission deletion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
291
lib/services/leantime-service.ts
Normal file
291
lib/services/leantime-service.ts
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export class LeantimeService {
|
||||||
|
private apiUrl: string;
|
||||||
|
private apiToken: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.apiUrl = process.env.LEANTIME_API_URL || '';
|
||||||
|
this.apiToken = process.env.LEANTIME_TOKEN || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new project in Leantime
|
||||||
|
* @param mission The mission data
|
||||||
|
* @returns Project ID or throws error
|
||||||
|
*/
|
||||||
|
async createProject(mission: any): Promise<number> {
|
||||||
|
try {
|
||||||
|
// Determine client ID based on mission type
|
||||||
|
const clientId = mission.niveau.toLowerCase() === 'a' ?
|
||||||
|
await this.getClientIdByName('Enkun') :
|
||||||
|
await this.getClientIdByName('ONG');
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
throw new Error(`Leantime client not found for mission type ${mission.niveau}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the project
|
||||||
|
const response = await axios.post(
|
||||||
|
this.apiUrl,
|
||||||
|
{
|
||||||
|
method: 'leantime.rpc.Projects.Projects.addProject',
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
params: {
|
||||||
|
values: {
|
||||||
|
name: mission.name,
|
||||||
|
details: mission.intention || '',
|
||||||
|
clientId: clientId,
|
||||||
|
type: 'project',
|
||||||
|
psettings: 'restricted',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.apiToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.data || !response.data.result) {
|
||||||
|
throw new Error(`Failed to create Leantime project: ${JSON.stringify(response.data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = response.data.result;
|
||||||
|
console.log(`Created Leantime project with ID: ${projectId}`);
|
||||||
|
|
||||||
|
// If the mission has a logo, set it as project avatar
|
||||||
|
if (mission.logo) {
|
||||||
|
await this.setProjectAvatar(projectId, mission.logo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign users to the project
|
||||||
|
if (mission.missionUsers && mission.missionUsers.length > 0) {
|
||||||
|
await this.assignUsersToProject(projectId, mission.missionUsers);
|
||||||
|
}
|
||||||
|
|
||||||
|
return projectId;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating Leantime project:', error);
|
||||||
|
throw new Error(`Leantime integration failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set project avatar using mission logo
|
||||||
|
* @param projectId The Leantime project ID
|
||||||
|
* @param logoUrl The mission logo URL
|
||||||
|
*/
|
||||||
|
async setProjectAvatar(projectId: number, logoPath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Get the logo file from the storage
|
||||||
|
const logoResponse = await fetch(`/api/missions/image/${logoPath}`);
|
||||||
|
if (!logoResponse.ok) {
|
||||||
|
throw new Error(`Failed to fetch logo file: ${logoResponse.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
const logoBlob = await logoResponse.blob();
|
||||||
|
|
||||||
|
// Add the file to form data
|
||||||
|
formData.append('file', logoBlob, 'logo.png');
|
||||||
|
formData.append('project', JSON.stringify({ id: projectId }));
|
||||||
|
|
||||||
|
// Upload the avatar
|
||||||
|
const response = await axios.post(
|
||||||
|
`${this.apiUrl.replace('/api/jsonrpc.php', '')}/api/v1/projects.setProjectAvatar`,
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
'Authorization': `Bearer ${this.apiToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.data || !response.data.success) {
|
||||||
|
throw new Error(`Failed to set project avatar: ${JSON.stringify(response.data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Set avatar for Leantime project ${projectId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting project avatar:', error);
|
||||||
|
// Don't fail the entire process if avatar upload fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign mission users to the Leantime project
|
||||||
|
* @param projectId The Leantime project ID
|
||||||
|
* @param missionUsers The mission users with roles
|
||||||
|
*/
|
||||||
|
async assignUsersToProject(projectId: number, missionUsers: any[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
for (const missionUser of missionUsers) {
|
||||||
|
// Get or create the user in Leantime
|
||||||
|
const leantimeUserId = await this.getUserByEmail(missionUser.user.email);
|
||||||
|
if (!leantimeUserId) {
|
||||||
|
console.warn(`User not found in Leantime: ${missionUser.user.email}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine role (Gardien du Temps gets editor, others get commenter)
|
||||||
|
const role = missionUser.role === 'gardien-temps' ? 'editor' : 'commenter';
|
||||||
|
|
||||||
|
// Assign the user to the project
|
||||||
|
await this.assignUserToProject(projectId, leantimeUserId, role);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error assigning users to project:', error);
|
||||||
|
// Continue even if some user assignments fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign a user to a project with the specified role
|
||||||
|
* @param projectId The Leantime project ID
|
||||||
|
* @param userId The Leantime user ID
|
||||||
|
* @param role The role to assign
|
||||||
|
*/
|
||||||
|
async assignUserToProject(projectId: number, userId: string, role: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
this.apiUrl,
|
||||||
|
{
|
||||||
|
method: 'leantime.rpc.Projects.Projects.assignUserToProject',
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
params: {
|
||||||
|
projectId,
|
||||||
|
userId,
|
||||||
|
role
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.apiToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.data || !response.data.result) {
|
||||||
|
throw new Error(`Failed to assign user to project: ${JSON.stringify(response.data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Assigned user ${userId} to project ${projectId} with role ${role}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error assigning user ${userId} to project ${projectId}:`, error);
|
||||||
|
// Don't fail if individual user assignment fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a client ID by name
|
||||||
|
* @param clientName The client name to search for
|
||||||
|
* @returns The client ID or null if not found
|
||||||
|
*/
|
||||||
|
async getClientIdByName(clientName: string): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
this.apiUrl,
|
||||||
|
{
|
||||||
|
method: 'leantime.rpc.Clients.Clients.getAll',
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.apiToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.data || !response.data.result) {
|
||||||
|
throw new Error(`Failed to get clients: ${JSON.stringify(response.data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clients = response.data.result;
|
||||||
|
const client = clients.find((c: any) =>
|
||||||
|
c.name.toLowerCase() === clientName.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
return client ? parseInt(client.id) : null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting client by name:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a user ID by email
|
||||||
|
* @param email The user email to search for
|
||||||
|
* @returns The user ID or null if not found
|
||||||
|
*/
|
||||||
|
async getUserByEmail(email: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
this.apiUrl,
|
||||||
|
{
|
||||||
|
method: 'leantime.rpc.Users.Users.getAll',
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.apiToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.data || !response.data.result) {
|
||||||
|
throw new Error(`Failed to get users: ${JSON.stringify(response.data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = response.data.result;
|
||||||
|
const user = users.find((u: any) => u.email === email);
|
||||||
|
|
||||||
|
return user ? user.id : null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting user by email:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a project from Leantime
|
||||||
|
* @param projectId The Leantime project ID to delete
|
||||||
|
* @returns True if successful, false otherwise
|
||||||
|
*/
|
||||||
|
async deleteProject(projectId: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
this.apiUrl,
|
||||||
|
{
|
||||||
|
method: 'leantime.rpc.Projects.Projects.deleteProject',
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
params: {
|
||||||
|
id: projectId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.apiToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data && response.data.result === true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error deleting Leantime project ${projectId}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
172
lib/services/outline-service.ts
Normal file
172
lib/services/outline-service.ts
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export class OutlineService {
|
||||||
|
private apiUrl: string;
|
||||||
|
private apiToken: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.apiUrl = process.env.OUTLINE_API_URL || 'https://app.getoutline.com/api';
|
||||||
|
this.apiToken = process.env.OUTLINE_API_KEY || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new collection in Outline
|
||||||
|
* @param mission The mission data
|
||||||
|
* @returns Collection ID or throws error
|
||||||
|
*/
|
||||||
|
async createCollection(mission: any): Promise<string> {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${this.apiUrl}/collections.create`,
|
||||||
|
{
|
||||||
|
name: mission.name,
|
||||||
|
description: mission.intention || '',
|
||||||
|
permission: 'read',
|
||||||
|
private: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.apiToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.data || !response.data.data || !response.data.data.id) {
|
||||||
|
throw new Error(`Failed to create Outline collection: ${JSON.stringify(response.data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectionId = response.data.data.id;
|
||||||
|
console.log(`Created Outline collection with ID: ${collectionId}`);
|
||||||
|
|
||||||
|
// Assign users to the collection
|
||||||
|
if (mission.missionUsers && mission.missionUsers.length > 0) {
|
||||||
|
await this.assignUsersToCollection(collectionId, mission.missionUsers);
|
||||||
|
}
|
||||||
|
|
||||||
|
return collectionId;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating Outline collection:', error);
|
||||||
|
throw new Error(`Outline integration failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign mission users to the Outline collection
|
||||||
|
* @param collectionId The Outline collection ID
|
||||||
|
* @param missionUsers The mission users with roles
|
||||||
|
*/
|
||||||
|
async assignUsersToCollection(collectionId: string, missionUsers: any[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
for (const missionUser of missionUsers) {
|
||||||
|
// Get the user in Outline
|
||||||
|
const outlineUserId = await this.getUserByEmail(missionUser.user.email);
|
||||||
|
if (!outlineUserId) {
|
||||||
|
console.warn(`User not found in Outline: ${missionUser.user.email}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine permission (Gardien de la Mémoire gets admin, others get read)
|
||||||
|
const permission = missionUser.role === 'gardien-memoire' ? 'admin' : 'read';
|
||||||
|
|
||||||
|
// Add the user to the collection
|
||||||
|
await this.addUserToCollection(collectionId, outlineUserId, permission);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error assigning users to collection:', error);
|
||||||
|
// Continue even if some user assignments fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a user to a collection with the specified permission
|
||||||
|
* @param collectionId The Outline collection ID
|
||||||
|
* @param userId The Outline user ID
|
||||||
|
* @param permission The permission to assign
|
||||||
|
*/
|
||||||
|
async addUserToCollection(collectionId: string, userId: string, permission: 'read' | 'admin'): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${this.apiUrl}/collections.add_user`,
|
||||||
|
{
|
||||||
|
id: collectionId,
|
||||||
|
userId: userId,
|
||||||
|
permission: permission
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.apiToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.data || !response.data.success) {
|
||||||
|
throw new Error(`Failed to add user to collection: ${JSON.stringify(response.data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Added user ${userId} to collection ${collectionId} with permission ${permission}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error adding user ${userId} to collection ${collectionId}:`, error);
|
||||||
|
// Don't fail if individual user assignment fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a user ID by email
|
||||||
|
* @param email The user email to search for
|
||||||
|
* @returns The user ID or null if not found
|
||||||
|
*/
|
||||||
|
async getUserByEmail(email: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${this.apiUrl}/users.info`,
|
||||||
|
{
|
||||||
|
email: email
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.apiToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.data || !response.data.data || !response.data.data.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data.data.id;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting user by email:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a collection from Outline
|
||||||
|
* @param collectionId The Outline collection ID to delete
|
||||||
|
* @returns True if successful, false otherwise
|
||||||
|
*/
|
||||||
|
async deleteCollection(collectionId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${this.apiUrl}/collections.delete`,
|
||||||
|
{
|
||||||
|
id: collectionId
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.apiToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data && response.data.success === true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error deleting Outline collection ${collectionId}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
221
lib/services/rocketchat-service.ts
Normal file
221
lib/services/rocketchat-service.ts
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export class RocketChatService {
|
||||||
|
private apiUrl: string;
|
||||||
|
private authToken: string;
|
||||||
|
private userId: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Extract the base URL from the iframe URL
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL?.split('/channel')[0] || '';
|
||||||
|
this.apiUrl = baseUrl;
|
||||||
|
this.authToken = process.env.ROCKET_CHAT_TOKEN || '';
|
||||||
|
this.userId = process.env.ROCKET_CHAT_USER_ID || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new channel in Rocket.Chat
|
||||||
|
* @param mission The mission data
|
||||||
|
* @returns Channel ID or throws error
|
||||||
|
*/
|
||||||
|
async createChannel(mission: any): Promise<string> {
|
||||||
|
try {
|
||||||
|
// First, get all mission users that need to be added to the channel
|
||||||
|
const missionUserEmails = mission.missionUsers.map((mu: any) => mu.user.email);
|
||||||
|
const rocketChatUsernames: string[] = [];
|
||||||
|
|
||||||
|
for (const email of missionUserEmails) {
|
||||||
|
const user = await this.getUserByEmail(email);
|
||||||
|
if (user) {
|
||||||
|
rocketChatUsernames.push(user.username);
|
||||||
|
} else {
|
||||||
|
console.warn(`User not found in Rocket.Chat: ${email}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize the channel name to comply with Rocket.Chat restrictions
|
||||||
|
const channelName = this.sanitizeChannelName(mission.name);
|
||||||
|
|
||||||
|
// Create the channel
|
||||||
|
const response = await axios.post(
|
||||||
|
`${this.apiUrl}/api/v1/channels.create`,
|
||||||
|
{
|
||||||
|
name: channelName,
|
||||||
|
members: rocketChatUsernames,
|
||||||
|
readOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Auth-Token': this.authToken,
|
||||||
|
'X-User-Id': this.userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.data || !response.data.success) {
|
||||||
|
throw new Error(`Failed to create Rocket.Chat channel: ${JSON.stringify(response.data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelId = response.data.channel._id;
|
||||||
|
console.log(`Created Rocket.Chat channel with ID: ${channelId}`);
|
||||||
|
|
||||||
|
// Make "Gardien de la Parole" users channel admins
|
||||||
|
await this.setChannelAdmins(channelId, mission.missionUsers);
|
||||||
|
|
||||||
|
return channelId;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating Rocket.Chat channel:', error);
|
||||||
|
throw new Error(`Rocket.Chat integration failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make "Gardien de la Parole" users channel admins
|
||||||
|
* @param channelId The Rocket.Chat channel ID
|
||||||
|
* @param missionUsers The mission users with roles
|
||||||
|
*/
|
||||||
|
async setChannelAdmins(channelId: string, missionUsers: any[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Find "Gardien de la Parole" users
|
||||||
|
const gardienParoleUsers = missionUsers.filter((mu: any) => mu.role === 'gardien-parole');
|
||||||
|
|
||||||
|
for (const gardienUser of gardienParoleUsers) {
|
||||||
|
const user = await this.getUserByEmail(gardienUser.user.email);
|
||||||
|
if (user) {
|
||||||
|
await this.makeUserChannelOwner(channelId, user._id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting channel admins:', error);
|
||||||
|
// Don't fail if setting admins fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a user a channel owner
|
||||||
|
* @param channelId The Rocket.Chat channel ID
|
||||||
|
* @param userId The Rocket.Chat user ID
|
||||||
|
*/
|
||||||
|
async makeUserChannelOwner(channelId: string, userId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${this.apiUrl}/api/v1/channels.addOwner`,
|
||||||
|
{
|
||||||
|
roomId: channelId,
|
||||||
|
userId: userId
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Auth-Token': this.authToken,
|
||||||
|
'X-User-Id': this.userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.data || !response.data.success) {
|
||||||
|
throw new Error(`Failed to make user channel owner: ${JSON.stringify(response.data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Made user ${userId} an owner of channel ${channelId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error making user ${userId} channel owner:`, error);
|
||||||
|
// Don't fail if individual user assignment fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a user by email
|
||||||
|
* @param email The user email to search for
|
||||||
|
* @returns The user object or null if not found
|
||||||
|
*/
|
||||||
|
async getUserByEmail(email: string): Promise<any | null> {
|
||||||
|
try {
|
||||||
|
// First try exact email match
|
||||||
|
const response = await axios.get(
|
||||||
|
`${this.apiUrl}/api/v1/users.list`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'X-Auth-Token': this.authToken,
|
||||||
|
'X-User-Id': this.userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.data || !response.data.success) {
|
||||||
|
throw new Error(`Failed to get users: ${JSON.stringify(response.data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// First check if user exists by email
|
||||||
|
const userByEmail = response.data.users.find((user: any) =>
|
||||||
|
user.emails?.some((e: any) => e.address === email)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userByEmail) {
|
||||||
|
return userByEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not found by email, try by username (username might be the part before @)
|
||||||
|
const username = email.split('@')[0];
|
||||||
|
const userByUsername = response.data.users.find((user: any) => user.username === username);
|
||||||
|
|
||||||
|
return userByUsername || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting user by email:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a channel name to comply with Rocket.Chat restrictions
|
||||||
|
* @param name The original name
|
||||||
|
* @returns The sanitized name
|
||||||
|
*/
|
||||||
|
private sanitizeChannelName(name: string): string {
|
||||||
|
// Replace spaces and invalid characters with hyphens
|
||||||
|
// Channel names must match regex [0-9a-zA-Z-_.]+
|
||||||
|
let sanitized = name.replace(/[^0-9a-zA-Z-_.]/g, '-');
|
||||||
|
|
||||||
|
// Ensure no sequential hyphens
|
||||||
|
sanitized = sanitized.replace(/-+/g, '-');
|
||||||
|
|
||||||
|
// Trim hyphens from beginning and end
|
||||||
|
sanitized = sanitized.replace(/^-+|-+$/g, '');
|
||||||
|
|
||||||
|
// Ensure name is not empty after sanitization
|
||||||
|
if (!sanitized) {
|
||||||
|
sanitized = 'mission-channel';
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a channel from Rocket.Chat
|
||||||
|
* @param channelId The Rocket.Chat channel ID to delete
|
||||||
|
* @returns True if successful, false otherwise
|
||||||
|
*/
|
||||||
|
async deleteChannel(channelId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${this.apiUrl}/api/v1/channels.delete`,
|
||||||
|
{
|
||||||
|
roomId: channelId
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Auth-Token': this.authToken,
|
||||||
|
'X-User-Id': this.userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data && response.data.success === true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error deleting Rocket.Chat channel ${channelId}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -138,6 +138,13 @@ model Mission {
|
|||||||
attachments Attachment[]
|
attachments Attachment[]
|
||||||
missionUsers MissionUser[]
|
missionUsers MissionUser[]
|
||||||
|
|
||||||
|
// External integration fields
|
||||||
|
leantimeProjectId String?
|
||||||
|
outlineCollectionId String?
|
||||||
|
rocketChatChannelId String?
|
||||||
|
giteaRepositoryUrl String?
|
||||||
|
penpotProjectId String?
|
||||||
|
|
||||||
@@index([creatorId])
|
@@index([creatorId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user