Taskr manages the complete personnel lifecycle from invitation through
offboarding, including automated notifications and access management.
Overview
The personnel lifecycle covers:- Invitations: Send and manage team member invitations
- Onboarding: Welcome new members and grant access
- Role Management: Assign and modify permissions
- Offboarding: Remove access and archive records
Personnel States
Onboarding Flow
Invitation Process
1
Send Invitation
Admin enters email and selects role for new team member:
// Create invitation with role
const invitation = await createInvitation({
teamId,
email: "[email protected]",
role: "member", // owner, admin, member
invitedBy: currentUser.id,
expiresAt: addDays(new Date(), 7),
});
2
Email Sent
System sends invitation email with unique link:
await sendEmail({
to: invitation.email,
subject: `You've been invited to join ${team.name}`,
react: TeamInvitationEmail({
teamName: team.name,
inviterName: currentUser.name,
inviteUrl: `${baseUrl}/invite/${invitation.token}`,
expiresAt: invitation.expiresAt,
}),
});
3
Accept Invitation
New member clicks link and completes signup:
// Validate invitation token
const invitation = await validateInvitationToken(token);
if (invitation.expiresAt < new Date()) {
throw new Error("Invitation has expired");
}
// Create user account if needed
const user = await createOrGetUser({
email: invitation.email,
name: formData.name,
});
// Link user to team with invited role
await addUserToTeam({
teamId: invitation.teamId,
userId: user.id,
role: invitation.role,
});
// Mark invitation as accepted
await markInvitationAccepted(invitation.id);
4
Welcome Process
New member receives welcome email and sees onboarding:
// packages/workbooks-jobs/src/tasks/personnel/workflows/onboarding.ts
export const onboardingWorkflow = task({
id: "personnel-onboarding",
}, async ({ userId, teamId }) => {
// Send welcome email
await sendWelcomeEmail(userId, teamId);
// Update status to active
await updateMemberStatus(userId, teamId, "active");
// Notify team owner
await notifyTeamOwner(teamId, {
type: "new_member_joined",
memberId: userId,
});
// Log onboarding event
await logOnboardingEvent(userId, teamId, "completed");
});
Role Hierarchy
| Role | Permissions |
|---|---|
| Owner | Full access, billing, delete team, transfer ownership |
| Admin | Manage members, all features, cannot delete team |
| Member | Standard access, cannot manage other members |
Role Capabilities Matrix
| Capability | Owner | Admin | Member |
|---|---|---|---|
| View all data | ✅ | ✅ | ✅ |
| Create/edit records | ✅ | ✅ | ✅ |
| Invite members | ✅ | ✅ | ❌ |
| Remove members | ✅ | ✅ | ❌ |
| Change member roles | ✅ | ✅ | ❌ |
| Manage billing | ✅ | ❌ | ❌ |
| Delete team | ✅ | ❌ | ❌ |
| Transfer ownership | ✅ | ❌ | ❌ |
Offboarding Flow
1
Initiate Offboarding
Admin selects member and initiates offboarding:
// Check for blocking dependencies
const dependencies = await checkMemberDependencies(memberId, teamId);
if (dependencies.assignedJobs.length > 0) {
// Must handle assigned jobs first
return {
blocked: true,
reason: "reassign_jobs",
jobs: dependencies.assignedJobs,
};
}
2
Handle Dependencies
Reassign or unassign any jobs/tasks:
// Reassign jobs to another member
for (const job of assignedJobs) {
await reassignJob(job.id, newAssigneeId);
}
// Or unassign all
await unassignAllJobs(memberId, teamId);
3
Revoke Access
Remove all access and sessions:
// packages/workbooks-jobs/src/tasks/personnel/workflows/offboarding.ts
export const offboardingWorkflow = task({
id: "personnel-offboarding",
}, async ({ userId, teamId, performedBy }) => {
// Revoke all sessions
await revokeUserSessions(userId, teamId);
// Send farewell email
await sendFarewellEmail(userId, teamId);
// Update status
await updateMemberStatus(userId, teamId, "inactive");
// Log offboarding
await logOffboardingEvent(userId, teamId, performedBy);
// Notify team owner
await notifyTeamOwner(teamId, {
type: "member_offboarded",
memberId: userId,
performedBy,
});
});
4
Archive Records
Member data is retained but marked inactive:
// Member record preserved for:
// - Historical job assignments
// - Timesheet records
// - Audit trail
await db.update(teamMembers).set({
status: "inactive",
offboardedAt: new Date().toISOString(),
offboardedBy: performedBy,
}).where(
and(
eq(teamMembers.userId, userId),
eq(teamMembers.teamId, teamId)
)
);
Email Templates
Invitation Email
// packages/workbooks-email/emails/team-invitation.tsx
export function TeamInvitationEmail({
teamName,
inviterName,
inviteUrl,
expiresAt,
}: Props) {
return (
<Email>
<Heading>You've been invited to join {teamName}</Heading>
<Text>
{inviterName} has invited you to collaborate on {teamName}.
</Text>
<Button href={inviteUrl}>Accept Invitation</Button>
<Text muted>
This invitation expires on {formatDate(expiresAt)}.
</Text>
</Email>
);
}
Welcome Email
// packages/workbooks-email/emails/welcome.tsx
export function WelcomeEmail({ userName, teamName }: Props) {
return (
<Email>
<Heading>Welcome to {teamName}!</Heading>
<Text>
Hi {userName}, your account is all set up.
</Text>
<Section title="Getting Started">
<Text>Here are a few things you can do:</Text>
<List>
<Item>View your assigned jobs</Item>
<Item>Track your time</Item>
<Item>Access team resources</Item>
</List>
</Section>
<Button href={dashboardUrl}>Go to Dashboard</Button>
</Email>
);
}
Farewell Email
// packages/workbooks-email/emails/farewell.tsx
export function FarewellEmail({ userName, teamName }: Props) {
return (
<Email>
<Heading>Farewell from {teamName}</Heading>
<Text>
Hi {userName}, your access to {teamName} has been removed.
</Text>
<Text>
If you believe this was a mistake, please contact your team administrator.
</Text>
</Email>
);
}
Notifications
| Event | Channels | Recipients |
|---|---|---|
| Invitation sent | Invitee | |
| Invitation accepted | Email, In-app | Team owner, Admin |
| Member joined | In-app, Slack | Team members |
| Role changed | Email, In-app | Affected member |
| Member offboarded | Email, In-app | Team owner |
// packages/workbooks-jobs/src/tasks/personnel/notifications/personnel-status-change.ts
export const notifyPersonnelStatusChange = task({
id: "notify-personnel-status-change",
}, async ({ userId, teamId, oldStatus, newStatus }) => {
const member = await getMember(userId, teamId);
const team = await getTeam(teamId);
// In-app notification
await createNotification({
teamId,
type: "personnel_status_change",
title: `${member.name}'s status changed`,
message: `Status changed from ${oldStatus} to ${newStatus}`,
priority: "normal",
targetRoles: ["owner", "admin"],
});
// Slack notification (if connected)
if (team.slackWebhookUrl) {
await sendSlackMessage(team.slackWebhookUrl, {
text: `${member.name} is now ${newStatus}`,
channel: team.slackChannel,
});
}
});
Bulk Operations
Bulk Invite
// Invite multiple members at once
const results = await bulkInvite({
teamId,
invitations: [
{ email: "[email protected]", role: "member" },
{ email: "[email protected]", role: "admin" },
{ email: "[email protected]", role: "member" },
],
invitedBy: currentUser.id,
});
// Returns success/failure for each
// {
// sent: ["[email protected]", "[email protected]"],
// failed: [{ email: "[email protected]", reason: "already_member" }],
// }
Bulk Role Update
// Update multiple member roles
await bulkUpdateRoles({
teamId,
updates: [
{ userId: "user1", role: "admin" },
{ userId: "user2", role: "member" },
],
performedBy: currentUser.id,
});
Database Schema
// Team Members
export const teamMembers = pgTable("team_members", {
id: uuid("id").primaryKey().defaultRandom(),
teamId: uuid("team_id").references(() => teams.id),
userId: uuid("user_id").references(() => users.id),
role: text("role").notNull(), // owner, admin, member
status: text("status").notNull(), // active, inactive, suspended
// Timestamps
joinedAt: timestamp("joined_at").defaultNow(),
offboardedAt: timestamp("offboarded_at"),
offboardedBy: uuid("offboarded_by").references(() => users.id),
// Settings
receiveEmailNotifications: boolean("receive_email_notifications").default(true),
receiveInAppNotifications: boolean("receive_in_app_notifications").default(true),
});
// Invitations
export const teamInvitations = pgTable("team_invitations", {
id: uuid("id").primaryKey().defaultRandom(),
teamId: uuid("team_id").references(() => teams.id),
email: text("email").notNull(),
role: text("role").notNull(),
token: text("token").notNull().unique(),
// Status
status: text("status").notNull(), // pending, accepted, expired, revoked
invitedBy: uuid("invited_by").references(() => users.id),
acceptedBy: uuid("accepted_by").references(() => users.id),
// Timestamps
createdAt: timestamp("created_at").defaultNow(),
expiresAt: timestamp("expires_at").notNull(),
acceptedAt: timestamp("accepted_at"),
});
API Endpoints
| Endpoint | Method | Description |
|---|---|---|
team.members.list | Query | List all team members |
team.members.getById | Query | Get member details |
team.members.invite | Mutation | Send invitation |
team.members.resendInvite | Mutation | Resend expired invitation |
team.members.updateRole | Mutation | Change member role |
team.members.offboard | Mutation | Remove member access |
team.members.reactivate | Mutation | Restore inactive member |
team.invitations.list | Query | List pending invitations |
team.invitations.revoke | Mutation | Cancel invitation |
Related Documentation
Subscription Billing
Seat-based billing affected by team size
Notification System
Configure notification preferences