Workspaces are the multi-tenancy layer of the Task Manager — they isolate one team’s tasks from another’s, enforce membership-based access control, and manage the invitation flow that brings new members in. The workspace feature requires more design consideration than tasks because it involves relationships between users and workspaces (a user can belong to many workspaces with different roles in each), invitation lifecycle (pending → accepted/declined/expired), and the role hierarchy (owner > admin > member > viewer) that governs what each role can do.
Workspace Permission Matrix
| Action | Viewer | Member | Admin | Owner |
|---|---|---|---|---|
| View tasks | ✅ | ✅ | ✅ | ✅ |
| Create tasks | ❌ | ✅ | ✅ | ✅ |
| Edit own tasks | ❌ | ✅ | ✅ | ✅ |
| Edit all tasks | ❌ | ❌ | ✅ | ✅ |
| Delete tasks | ❌ | Own only | ✅ | ✅ |
| Invite members | ❌ | ❌ | ✅ | ✅ |
| Change member roles | ❌ | ❌ | Below admin only | ✅ |
| Remove members | ❌ | ❌ | ✅ | ✅ |
| Edit workspace settings | ❌ | ❌ | ✅ | ✅ |
| Delete workspace | ❌ | ❌ | ❌ | ✅ |
acme-corp-dev) must be unique globally, not just within a user’s workspaces. Generate it from the workspace name on creation using a slugify function, then check for uniqueness. If the generated slug is taken, append a short random suffix. Store the slug in a unique index. The URL pattern /w/:workspaceSlug/tasks uses the slug rather than the MongoDB ObjectId — making URLs readable and shareable. The Angular workspace guard resolves the slug to a workspace ID by querying the API.Invitation collection rather than embedding pending invitations in the Workspace document. Invitations are queried independently (user’s pending invitations page), expire and need TTL cleanup, and may need to be cancelled individually. Embedding them in the workspace means loading the entire workspace document to manage invitations. A separate collection with a compound unique index on { workspaceId, email } and a TTL index on expiresAt is the cleaner design.if (requestingRole === 'admin' && newRole === 'admin') throw new AuthorizationError('Admins cannot promote to admin'). The owner role can only be transferred by the current owner — add an explicit ownership transfer endpoint with confirmation flow rather than allowing role changes to owner through the normal role update endpoint.Complete Workspace Implementation
// ── Workspace model (abbreviated) ─────────────────────────────────────────
const workspaceSchema = new mongoose.Schema({
name: { type: String, required: true, trim: true, maxlength: 100 },
slug: { type: String, required: true, lowercase: true, trim: true },
description:{ type: String, maxlength: 500 },
avatarUrl: { type: String },
members: [{
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
role: { type: String, enum: ['owner','admin','member','viewer'], required: true },
joinedAt: { type: Date, default: Date.now },
}],
createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
}, { timestamps: true });
workspaceSchema.index({ slug: 1 }, { unique: true });
workspaceSchema.index({ 'members.userId': 1 }); // find workspaces for a user
// ── Invitation model ──────────────────────────────────────────────────────
const invitationSchema = new mongoose.Schema({
workspace: { type: mongoose.Schema.Types.ObjectId, ref: 'Workspace', required: true },
email: { type: String, required: true, lowercase: true },
role: { type: String, enum: ['admin','member','viewer'], default: 'member' },
token: { type: String, required: true, select: false }, // hashed
invitedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
status: { type: String, enum: ['pending','accepted','declined'], default: 'pending' },
expiresAt: { type: Date, required: true },
}, { timestamps: true });
invitationSchema.index({ workspace: 1, email: 1 }, { unique: true });
invitationSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); // TTL auto-delete
invitationSchema.index({ token: 1 }, { sparse: true });
// ── Workspace service ─────────────────────────────────────────────────────
exports.create = async (dto, ownerId) => {
const slug = await generateUniqueSlug(dto.name);
const workspace = await Workspace.create({
...dto,
slug,
createdBy: ownerId,
members: [{ userId: ownerId, role: 'owner', joinedAt: new Date() }],
});
// Update user's workspace count
await User.findByIdAndUpdate(ownerId, { $inc: { workspaceCount: 1 } });
return workspace;
};
async function generateUniqueSlug(name) {
const base = name.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.slice(0, 50);
let slug = base;
let attempt = 0;
while (await Workspace.exists({ slug })) {
slug = `${base}-${Math.random().toString(36).slice(2, 6)}`;
if (++attempt > 5) throw new Error('Could not generate unique slug');
}
return slug;
}
exports.invite = async (workspaceId, inviterRole, { email, role }) => {
// Only admins and owners can invite
if (!['admin', 'owner'].includes(inviterRole)) {
throw new AuthorizationError('Only admins can invite members');
}
// Can't invite someone already a member
const existing = await Workspace.findOne({
_id: workspaceId, 'members.userId': await User.findOne({ email }).select('_id'),
});
if (existing) throw new ConflictError('User is already a workspace member');
// Upsert invitation — resend if already pending
const raw = crypto.randomBytes(32).toString('hex');
const hashed = crypto.createHash('sha256').update(raw).digest('hex');
await Invitation.findOneAndUpdate(
{ workspace: workspaceId, email },
{
token: hashed,
role,
status: 'pending',
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
{ upsert: true, new: true }
);
await emailQueue.add('workspaceInvite', {
type: 'WORKSPACE_INVITE',
to: email,
data: { role, inviteUrl: `${process.env.CLIENT_URL}/invitations/${raw}` },
});
return raw;
};
exports.acceptInvitation = async (rawToken, userId) => {
const hashed = crypto.createHash('sha256').update(rawToken).digest('hex');
const invitation= await Invitation.findOne({ token: hashed, status: 'pending' })
.populate('workspace');
if (!invitation || invitation.expiresAt < new Date()) {
throw new ValidationError('Invitation invalid or expired');
}
// Add user to workspace
await Workspace.findByIdAndUpdate(invitation.workspace._id, {
$addToSet: { members: { userId, role: invitation.role, joinedAt: new Date() } },
});
invitation.status = 'accepted';
await invitation.save();
return invitation.workspace;
};
exports.updateMemberRole = async (workspaceId, targetUserId, newRole, requesterId, requesterRole) => {
// Prevent self-modification
if (requesterId === targetUserId) {
throw new AuthorizationError('Cannot change your own role');
}
// Admins cannot promote to admin or owner
if (requesterRole === 'admin' && ['admin', 'owner'].includes(newRole)) {
throw new AuthorizationError('Admins can only assign member and viewer roles');
}
// Cannot modify the owner's role
const workspace = await Workspace.findById(workspaceId);
const target = workspace.members.find(m => m.userId.toString() === targetUserId);
if (!target) throw new NotFoundError('Member', targetUserId);
if (target.role === 'owner') throw new AuthorizationError('Cannot change owner role');
await Workspace.findOneAndUpdate(
{ _id: workspaceId, 'members.userId': targetUserId },
{ $set: { 'members.$.role': newRole } }
);
};
How It Works
Step 1 — Slug Generation with Collision Handling
The slug is generated from the workspace name by lowercasing, removing non-alphanumeric characters, and replacing spaces with hyphens. A unique index on slug ensures no two workspaces share a slug. If the generated slug is taken, a 4-character random suffix is appended and retried. The loop caps at 5 attempts — after that, an error is thrown (extremely rare, only if the name is extremely common). The slug never changes after creation to preserve stable URLs.
Step 2 — Invitation Upsert Handles Re-invitations
findOneAndUpdate with upsert: true on { workspace, email } creates a new invitation if none exists, or updates the existing one (refreshing the token and expiry) if one does. This handles the case where an admin invites the same email twice — the second invite resends with a fresh token rather than creating a duplicate. The invitation email is always re-sent with the latest token.
Step 3 — $addToSet Prevents Duplicate Workspace Membership
Accepting an invitation uses $addToSet to add the user to the workspace members array. If the user is somehow already a member (e.g. accepted the same invitation twice via a browser bug), $addToSet is idempotent — no duplicate is added. This makes the accept endpoint safe to call multiple times without producing incorrect state.
Step 4 — Positional Operator Updates Embedded Array
{ $set: { 'members.$.role': newRole } } with filter 'members.userId': targetUserId uses the positional $ operator to update the specific array element whose userId matched the filter. This is the standard pattern for updating a specific element within an embedded array without reading the entire document first.
Step 5 — Role Validation at Service Layer Prevents Privilege Escalation
The permission checks (cannot change own role, admins cannot grant admin/owner, cannot change owner) run in the service layer — before any database write. This centralises the authorization logic: whether the request comes from the REST API, an admin panel, or a background job, these rules are always enforced. The middleware layer checks workspace membership; the service layer checks whether the action is permitted given the roles involved.
Quick Reference
| Task | Code |
|---|---|
| Generate unique slug | Slugify name → check unique index → retry with suffix if taken |
| Find user’s workspaces | Workspace.find({ 'members.userId': userId }) |
| Check membership & role | workspace.members.find(m => m.userId.equals(userId)) |
| Update member role | { $set: { 'members.$.role': newRole } } with positional operator |
| Remove member | { $pull: { members: { userId } } } |
| Upsert invitation | findOneAndUpdate({ workspace, email }, data, { upsert: true }) |
| Hash invitation token | crypto.createHash('sha256').update(raw).digest('hex') |
| TTL expire invitations | schema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }) |