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
936 changes: 933 additions & 3 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"node": ">=20.0.0"
},
"dependencies": {
"@ant-design/icons": "^6.2.3",
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
Expand All @@ -28,6 +29,7 @@
"@tiptap/starter-kit": "^3.20.0",
"@xyflow/react": "^12.10.0",
"agora-rtc-sdk-ng": "^4.24.0",
"antd": "^6.4.3",
"clsx": "^2.1.1",
"cross-env": "^7.0.3",
"date-fns": "^4.1.0",
Expand Down
52 changes: 52 additions & 0 deletions src/core/hooks/useProjectRole.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useMemo } from 'react';
import { useRole } from './useRole';

/**
* Hook to evaluate the current user's granular permissions within a specific project.
* @param {Object} project - The project object containing publisher and members arrays.
*/
export const useProjectRole = (project) => {
const { user, isAuthenticated } = useRole();

return useMemo(() => {
if (!isAuthenticated || !user || !project) {
return {
isPublisher: false,
isMember: false,
permissions: {},
canWriteTasks: false,
canWriteSprints: false,
canWriteCheckpoints: false,
canManageMembers: false,
isAdmin: false,
};
}

// Check if user is publisher
// In Strapi, relations might expose id or documentId or both.
const isPublisher = project.publisher?.id === user.id || project.publisher?.documentId === user.documentId;

// Check membership
const memberRecord = project.members?.find(
m => m.user?.id === user.id || m.user?.documentId === user.documentId
);
const isMember = !!memberRecord;

// Extract granular permissions from the member's project_role
const permissions = memberRecord?.projectRole?.permissions || {};

// Admins and publishers have all rights implicitly
const isAdmin = isPublisher || permissions.admin === true;

return {
isPublisher,
isMember,
permissions,
canWriteTasks: isAdmin || permissions.write_tasks === true,
canWriteSprints: isAdmin || permissions.write_sprints === true,
canWriteCheckpoints: isAdmin || permissions.write_checkpoints === true,
canManageMembers: isAdmin || permissions.manage_members === true,
isAdmin,
};
}, [project, user, isAuthenticated]);
};
25 changes: 25 additions & 0 deletions src/domain/entity/JobAdEntity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export class JobAdEntity {
constructor(data = {}) {
this.type = 'job_ad';
this.roleId = data.roleId;
this.roleUid = data.roleUid;
this.jobTitle = data.jobTitle; // { slug, labelAr, labelEn }
this.customLabel = data.customLabel;
this.requiredLevel = data.requiredLevel;
this.slots = data.slots;
this.isMatched = data.isMatched;
this.hasApplied = data.hasApplied;

// Embedded Project summary
this.project = data.project;
/*
{
uid,
title,
methodology,
status,
publisher: { username, uid }
}
*/
}
}
30 changes: 30 additions & 0 deletions src/domain/entity/LessonEntity.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ export class LessonEntity extends ContentEntity {
this.description = props.description;
this.isPublic = props.isPublic || false;
this.instructor = props.instructor;

// Embedded video support
this.embedSource = props.embedSource || null;
this.embedUrl = props.embedUrl || null;
this.embedMetadata = props.embedMetadata || null;
}

/**
* Checks if the lesson is an embedded external video.
* @returns {boolean}
*/
get isEmbedded() {
return this.type === 'embedded';
}

/**
Expand All @@ -40,4 +53,21 @@ export class LessonEntity extends ContentEntity {
get isArticle() {
return this.type === 'article';
}

/**
* Returns the provider-specific embed URL for iframe rendering.
* Falls back to raw embedUrl if metadata is missing.
* @returns {string|null}
*/
get embedPlayerUrl() {
return this.embedMetadata?.embedUrl || this.embedUrl || null;
}

/**
* Returns the cached thumbnail from the provider.
* @returns {string|null}
*/
get embedThumbnail() {
return this.embedMetadata?.thumbnail || null;
}
}
73 changes: 73 additions & 0 deletions src/domain/entity/ProjectEntity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
export class ProjectEntity {
constructor(data = {}) {
this.id = data.id;
this.uid = data.uid;
this.title = data.title;
this.description = data.description;
this.methodology = data.methodology;
this.status = data.status;
this.visibility = data.visibility;
this.githubRepoUrl = data.githubRepoUrl;
this.githubRepoId = data.githubRepoId;
this.engagementScore = data.engagementScore;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;

this.publisher = data.publisher; // UserEntity
this.roles = data.roles || []; // Array of ProjectRoleEntity
this.members = data.members || []; // Array of ProjectMemberEntity
this.sprints = data.sprints || []; // Array of SprintEntity
this.wikiDocs = data.wikiDocs;
this.architectureDiagram = data.architectureDiagram;
}
}

export class ProjectRoleEntity {
constructor(data = {}) {
this.id = data.id;
this.uid = data.uid;
this.customLabel = data.customLabel;
this.permissions = data.permissions || {};
this.isOpen = data.isOpen;
this.requiredLevel = data.requiredLevel;
this.slots = data.slots;
this.jobTitleTag = data.jobTitleTag; // JobTitleTagEntity
}
}

export class ProjectMemberEntity {
constructor(data = {}) {
this.id = data.id;
this.uid = data.uid;
this.user = data.user; // UserEntity
this.projectRole = data.projectRole; // ProjectRoleEntity
this.githubUsername = data.githubUsername;
this.isActive = data.isActive;
this.joinedAt = data.joinedAt;
}
}

export class ProjectApplicationEntity {
constructor(data = {}) {
this.id = data.id;
this.uid = data.uid;
this.type = data.type;
this.status = data.status;
this.message = data.message;
this.createdAt = data.createdAt;
this.user = data.user; // UserEntity
this.project = data.project; // ProjectEntity
this.projectRole = data.projectRole; // ProjectRoleEntity
}
}

export class JobTitleTagEntity {
constructor(data = {}) {
this.id = data.id;
this.uid = data.uid;
this.slug = data.slug;
this.labelAr = data.labelAr;
this.labelEn = data.labelEn;
this.category = data.category;
}
}
21 changes: 21 additions & 0 deletions src/domain/entity/ReviewRequestEntity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export class ReviewRequestEntity {
constructor(data = {}) {
this.id = data.id;
this.uid = data.uid;
this.projectId = data.projectId;
this.taskId = data.taskId;
this.task = data.task; // TaskEntity
this.workspaceItemId = data.workspaceItemId;
this.workspaceItem = data.workspaceItem; // WorkspaceItemEntity
this.externalLink = data.externalLink;
this.requesterId = data.requesterId;
this.requester = data.requester; // ProjectMemberEntity
this.reviewerId = data.reviewerId;
this.reviewer = data.reviewer; // ProjectMemberEntity
this.status = data.status; // pending, approved, changes_requested, rejected
this.message = data.message;
this.feedback = data.feedback;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
}
}
55 changes: 55 additions & 0 deletions src/domain/entity/TaskEntity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
export class TaskEntity {
constructor(data = {}) {
this.id = data.id;
this.uid = data.uid;
this.title = data.title;
this.description = data.description;
this.status = data.status; // todo, in_progress, in_review, done, blocked
this.priority = data.priority;
this.branchPattern = data.branchPattern;
this.githubPrId = data.githubPrId;
this.ciStatus = data.ciStatus;
this.order = data.order;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;

this.assignee = data.assignee; // ProjectMemberEntity
this.sprint = data.sprint; // SprintEntity
this.projectId = data.projectId;

this.taskType = data.taskType || 'general';
this.layerId = data.layerId;
this.stage = data.stage || 'planning';
this.checkpointId = data.checkpointId; // documentId of parent checkpoint
this.checkpoint = data.checkpoint; // parent CheckpointEntity
}
}

export class SprintEntity {
constructor(data = {}) {
this.id = data.id;
this.uid = data.uid;
this.number = data.number;
this.goal = data.goal;
this.status = data.status; // planned, active, completed
this.startDate = data.startDate;
this.endDate = data.endDate;
this.retrospectiveNotes = data.retrospectiveNotes;
this.projectId = data.projectId;
}
}

export class CheckpointEntity {
constructor(data = {}) {
this.id = data.id;
this.uid = data.uid;
this.name = data.name;
this.stage = data.stage;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
this.sprint = data.sprint; // SprintEntity
this.tasks = data.tasks || []; // TaskEntity[]
this.projectId = data.projectId;
}
}

14 changes: 14 additions & 0 deletions src/domain/entity/WorkspaceItemEntity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export class WorkspaceItemEntity {
constructor(data = {}) {
this.id = data.id;
this.uid = data.uid;
this.projectId = data.projectId;
this.projectMemberId = data.projectMemberId;
this.type = data.type; // document, flowchart
this.title = data.title;
this.content = data.content; // Array of blocks or { nodes, edges }
this.status = data.status; // draft, submitted, merged, rejected
this.linkedTaskId = data.linkedTaskId;
this.linkedTask = data.linkedTask; // TaskEntity
}
}
7 changes: 7 additions & 0 deletions src/domain/interface/IProjectAccess.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export class IProjectAccess {
async getAll(params = {}) { throw new Error("Method not implemented."); }
async getById(id, params = {}) { throw new Error("Method not implemented."); }
async create(data) { throw new Error("Method not implemented."); }
async update(id, data) { throw new Error("Method not implemented."); }
async delete(id) { throw new Error("Method not implemented."); }
}
5 changes: 5 additions & 0 deletions src/domain/interface/IReviewRequestAccess.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class IReviewRequestAccess {
async getReviewRequests(projectId) { throw new Error("Method not implemented."); }
async createReviewRequest(projectId, data) { throw new Error("Method not implemented."); }
async resolveReviewRequest(projectId, requestId, data) { throw new Error("Method not implemented."); }
}
4 changes: 4 additions & 0 deletions src/domain/interface/ITaskAccess.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export class ITaskAccess {
async getAll(params = {}) { throw new Error("Method not implemented."); }
async getById(id, params = {}) { throw new Error("Method not implemented."); }
}
6 changes: 6 additions & 0 deletions src/domain/interface/IWorkspaceAccess.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class IWorkspaceAccess {
async getWorkspace(projectId) { throw new Error("Method not implemented."); }
async createWorkspaceItem(projectId, data) { throw new Error("Method not implemented."); }
async updateWorkspaceItem(projectId, itemId, data) { throw new Error("Method not implemented."); }
async deleteWorkspaceItem(projectId, itemId) { throw new Error("Method not implemented."); }
}
Loading
Loading