feat: Implement comprehensive onboarding improvements with role-based family invites
Some checks failed
ParentFlow CI/CD Pipeline / Backend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Frontend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Security Scanning (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-app/maternal-app-backend dockerfile:Dockerfile.production name:backend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-web dockerfile:Dockerfile.production name:frontend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Development (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled
Some checks failed
ParentFlow CI/CD Pipeline / Backend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Frontend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Security Scanning (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-app/maternal-app-backend dockerfile:Dockerfile.production name:backend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-web dockerfile:Dockerfile.production name:frontend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Development (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled
This commit adds a complete onboarding improvements system including progress tracking, streamlined UI, and role-based family invitation system. ## Backend Changes ### Database Migrations - Add onboarding tracking fields to users table (onboarding_completed, onboarding_step, onboarding_data) - Add role-based invite codes to families table (parent/caregiver/viewer codes with expiration) - Add indexes for fast invite code lookups ### User Preferences Module - Add UserPreferencesController with onboarding endpoints - Add UserPreferencesService with progress tracking methods - Add UpdateOnboardingProgressDto for validation - Endpoints: GET/PUT /api/v1/preferences/onboarding, POST /api/v1/preferences/onboarding/complete ### Families Module - Role-Based Invites - Add generateRoleInviteCode() - Generate role-specific codes with expiration - Add getRoleInviteCodes() - Retrieve all active codes for a family - Add joinFamilyWithRoleCode() - Join family with automatic role assignment - Add revokeRoleInviteCode() - Revoke specific role invite codes - Add sendEmailInvite() - Generate code and send email invitation - Endpoints: POST/GET/DELETE /api/v1/families/:id/invite-codes, POST /api/v1/families/join-with-role, POST /api/v1/families/:id/email-invite ### Email Service - Add sendFamilyInviteEmail() - Send role-based invitation emails - Beautiful HTML templates with role badges (👨👩👧 parent, 🤝 caregiver, 👁️ viewer) - Role-specific permission descriptions - Graceful fallback if email sending fails ### Auth Service - Fix duplicate family creation bug in joinFamily() - Ensure users only join family once during onboarding ## Frontend Changes ### Onboarding Page - Reduce steps from 5 to 4 (combined language + measurements) - Replace card-based selection with dropdown selectors - Add automatic progress saving after each step - Add progress restoration on page mount - Extract FamilySetupStep into reusable component ### Family Page - Add RoleInvitesSection component with accordion UI - Generate/view/copy/regenerate/revoke controls for each role - Send email invites directly from UI - Display expiration dates (e.g., "Expires in 5 days") - Info tooltips explaining role permissions - Only visible to users with parent role ### API Client - Add role-based invite methods to families API - Add onboarding progress methods to users API - TypeScript interfaces for all new data structures ## Features ✅ Streamlined 4-step onboarding with dropdown selectors ✅ Automatic progress save/restore across sessions ✅ Role-based family invites (parent/caregiver/viewer) ✅ Beautiful email invitations with role descriptions ✅ Automatic role assignment when joining with invite codes ✅ Granular permission control per role ✅ Email fallback if sending fails ✅ All changes tested and production-ready 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -102,6 +102,9 @@ PRODUCTION_DEPLOYMENT.md
|
|||||||
PRODUCTION_INSTALLATION.md
|
PRODUCTION_INSTALLATION.md
|
||||||
TESTING.md
|
TESTING.md
|
||||||
PACKAGE_UPGRADE_PLAN.md
|
PACKAGE_UPGRADE_PLAN.md
|
||||||
|
PUSH_NOTIFICATIONS_IMPLEMENTATION.md
|
||||||
|
PUSH_NOTIFICATIONS_PERSISTENCE_SUMMARY.md
|
||||||
|
pwa_web_push_implementation_plan.md
|
||||||
**/docs/*.md
|
**/docs/*.md
|
||||||
!README.md
|
!README.md
|
||||||
|
|
||||||
|
|||||||
@@ -316,6 +316,130 @@ export class EmailService {
|
|||||||
`.trim();
|
`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send family invite email with role-based invite code
|
||||||
|
*/
|
||||||
|
async sendFamilyInviteEmail(
|
||||||
|
to: string,
|
||||||
|
inviterName: string,
|
||||||
|
familyName: string,
|
||||||
|
role: 'parent' | 'caregiver' | 'viewer',
|
||||||
|
inviteCode: string,
|
||||||
|
expiresAt: Date,
|
||||||
|
): Promise<void> {
|
||||||
|
const subject = `${inviterName} invited you to join their family on Maternal App`;
|
||||||
|
const html = this.getFamilyInviteEmailTemplate(
|
||||||
|
inviterName,
|
||||||
|
familyName,
|
||||||
|
role,
|
||||||
|
inviteCode,
|
||||||
|
expiresAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.sendEmail({ to, subject, html });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Family invite email template
|
||||||
|
*/
|
||||||
|
private getFamilyInviteEmailTemplate(
|
||||||
|
inviterName: string,
|
||||||
|
familyName: string,
|
||||||
|
role: 'parent' | 'caregiver' | 'viewer',
|
||||||
|
inviteCode: string,
|
||||||
|
expiresAt: Date,
|
||||||
|
): string {
|
||||||
|
const roleDescriptions = {
|
||||||
|
parent:
|
||||||
|
'Full access - manage children, log activities, view reports, and invite others',
|
||||||
|
caregiver:
|
||||||
|
'Can edit children, log activities, and view reports (cannot add children or invite others)',
|
||||||
|
viewer: 'View-only access - can view reports but cannot log activities',
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleEmojis = {
|
||||||
|
parent: '👨👩👧',
|
||||||
|
caregiver: '🤝',
|
||||||
|
viewer: '👁️',
|
||||||
|
};
|
||||||
|
|
||||||
|
const expiryDate = new Date(expiresAt).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Family Invitation</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background-color: #f5f5f5; }
|
||||||
|
.container { max-width: 600px; margin: 40px auto; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||||
|
.header { background: linear-gradient(135deg, #FFB5A0 0%, #FF8B7D 100%); padding: 40px 20px; text-align: center; color: white; }
|
||||||
|
.header h1 { margin: 0; font-size: 28px; font-weight: 600; }
|
||||||
|
.content { padding: 40px 30px; }
|
||||||
|
.content p { margin: 0 0 20px 0; font-size: 16px; }
|
||||||
|
.invite-box { background: #FFF5F5; padding: 25px; margin: 25px 0; border-radius: 8px; border-left: 4px solid #FFB5A0; text-align: center; }
|
||||||
|
.invite-code { font-size: 32px; font-weight: 700; letter-spacing: 4px; color: #FF8B7D; margin: 15px 0; font-family: 'Courier New', monospace; }
|
||||||
|
.role-badge { display: inline-block; padding: 8px 16px; background: #FFB5A0; color: white; border-radius: 20px; font-size: 14px; font-weight: 600; margin: 10px 0; }
|
||||||
|
.permissions { background: #f9f9f9; padding: 20px; margin: 20px 0; border-radius: 6px; }
|
||||||
|
.permissions h3 { margin: 0 0 15px 0; color: #333; font-size: 16px; }
|
||||||
|
.permissions p { margin: 0; font-size: 14px; color: #666; }
|
||||||
|
.button { display: inline-block; padding: 14px 32px; background: #FF8B7D; color: white; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 16px; margin: 20px 0; }
|
||||||
|
.expiry { font-size: 14px; color: #999; margin-top: 15px; }
|
||||||
|
.footer { background: #f9f9f9; padding: 20px 30px; text-align: center; font-size: 14px; color: #666; }
|
||||||
|
.footer a { color: #FF8B7D; text-decoration: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>${roleEmojis[role]} You've Been Invited!</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p><strong>${inviterName}</strong> has invited you to join <strong>${familyName}</strong> on Maternal App.</p>
|
||||||
|
|
||||||
|
<div class="invite-box">
|
||||||
|
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666;">Your Invite Code:</p>
|
||||||
|
<div class="invite-code">${inviteCode}</div>
|
||||||
|
<div class="role-badge">${role.charAt(0).toUpperCase() + role.slice(1)} Role</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="permissions">
|
||||||
|
<h3>Your Permissions:</h3>
|
||||||
|
<p>${roleDescriptions[role]}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>To accept this invitation:</p>
|
||||||
|
<ol style="margin: 15px 0; padding-left: 25px;">
|
||||||
|
<li>Sign up or log in to Maternal App</li>
|
||||||
|
<li>Go to the "Join Family" section</li>
|
||||||
|
<li>Enter the invite code above</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<a href="${this.appUrl}" class="button">Open Maternal App</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="expiry">⏰ This invitation expires on ${expiryDate}</p>
|
||||||
|
|
||||||
|
<p style="font-size: 14px; color: #999; margin-top: 30px;">If you didn't expect this invitation, you can safely ignore this email.</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>Questions? Contact us at <a href="mailto:support@maternal-app.com">support@maternal-app.com</a></p>
|
||||||
|
<p>© ${new Date().getFullYear()} Maternal App. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strip HTML tags for plain text version
|
* Strip HTML tags for plain text version
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -23,6 +23,43 @@ export class Family {
|
|||||||
@Column({ name: 'share_code', length: 10, unique: true })
|
@Column({ name: 'share_code', length: 10, unique: true })
|
||||||
shareCode: string;
|
shareCode: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'share_code_expires_at',
|
||||||
|
type: 'timestamp without time zone',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
shareCodeExpiresAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'parent_invite_code', length: 10, nullable: true })
|
||||||
|
parentInviteCode?: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'parent_invite_expires_at',
|
||||||
|
type: 'timestamp without time zone',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
parentInviteExpiresAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'caregiver_invite_code', length: 10, nullable: true })
|
||||||
|
caregiverInviteCode?: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'caregiver_invite_expires_at',
|
||||||
|
type: 'timestamp without time zone',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
caregiverInviteExpiresAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'viewer_invite_code', length: 10, nullable: true })
|
||||||
|
viewerInviteCode?: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'viewer_invite_expires_at',
|
||||||
|
type: 'timestamp without time zone',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
viewerInviteExpiresAt?: Date | null;
|
||||||
|
|
||||||
@Column({ name: 'created_by', length: 20 })
|
@Column({ name: 'created_by', length: 20 })
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,25 @@ export class User {
|
|||||||
timeFormat?: '12h' | '24h';
|
timeFormat?: '12h' | '24h';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Onboarding tracking fields
|
||||||
|
@Column({ name: 'onboarding_completed', default: false })
|
||||||
|
onboardingCompleted: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'onboarding_step', default: 0 })
|
||||||
|
onboardingStep: number;
|
||||||
|
|
||||||
|
@Column({ name: 'onboarding_data', type: 'jsonb', nullable: true })
|
||||||
|
onboardingData?: {
|
||||||
|
selectedLanguage?: string;
|
||||||
|
selectedMeasurement?: 'metric' | 'imperial';
|
||||||
|
familyChoice?: 'create' | 'join';
|
||||||
|
childData?: {
|
||||||
|
name?: string;
|
||||||
|
birthDate?: string;
|
||||||
|
gender?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Admin/Role fields
|
// Admin/Role fields
|
||||||
@Column({ name: 'global_role', length: 20, default: 'parent' })
|
@Column({ name: 'global_role', length: 20, default: 'parent' })
|
||||||
globalRole: 'parent' | 'guest' | 'admin';
|
globalRole: 'parent' | 'guest' | 'admin';
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class UpdateFamilyRoleConstraint1736000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Drop old constraint if it exists
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE family_members
|
||||||
|
DROP CONSTRAINT IF EXISTS valid_family_role;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add new constraint with all 3 roles
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE family_members
|
||||||
|
ADD CONSTRAINT valid_family_role
|
||||||
|
CHECK (role IN ('parent', 'caregiver', 'viewer'));
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Update existing 'guest' roles to 'viewer' (if any exist)
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE family_members
|
||||||
|
SET role = 'viewer'
|
||||||
|
WHERE role = 'guest';
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Revert viewer back to guest
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE family_members
|
||||||
|
SET role = 'guest'
|
||||||
|
WHERE role = 'viewer';
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Drop new constraint
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE family_members
|
||||||
|
DROP CONSTRAINT IF EXISTS valid_family_role;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Restore old constraint
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE family_members
|
||||||
|
ADD CONSTRAINT valid_family_role
|
||||||
|
CHECK (role IN ('parent', 'guest'));
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddShareCodeExpiryToFamily1736000000001
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Add share_code_expires_at column to families table
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE families
|
||||||
|
ADD COLUMN share_code_expires_at TIMESTAMP;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create index on share_code for faster lookups when joining by code
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX idx_families_share_code
|
||||||
|
ON families(share_code)
|
||||||
|
WHERE share_code IS NOT NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Drop index
|
||||||
|
await queryRunner.query(`
|
||||||
|
DROP INDEX IF EXISTS idx_families_share_code;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Drop column
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE families
|
||||||
|
DROP COLUMN share_code_expires_at;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddRoleBasedInviteCodes1736000000002
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Add role-specific invite codes to families table
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE families
|
||||||
|
ADD COLUMN parent_invite_code VARCHAR(10),
|
||||||
|
ADD COLUMN parent_invite_expires_at TIMESTAMP,
|
||||||
|
ADD COLUMN caregiver_invite_code VARCHAR(10),
|
||||||
|
ADD COLUMN caregiver_invite_expires_at TIMESTAMP,
|
||||||
|
ADD COLUMN viewer_invite_code VARCHAR(10),
|
||||||
|
ADD COLUMN viewer_invite_expires_at TIMESTAMP;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create indexes for role-based invite codes
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX idx_families_parent_invite_code
|
||||||
|
ON families(parent_invite_code)
|
||||||
|
WHERE parent_invite_code IS NOT NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX idx_families_caregiver_invite_code
|
||||||
|
ON families(caregiver_invite_code)
|
||||||
|
WHERE caregiver_invite_code IS NOT NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX idx_families_viewer_invite_code
|
||||||
|
ON families(viewer_invite_code)
|
||||||
|
WHERE viewer_invite_code IS NOT NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Drop indexes
|
||||||
|
await queryRunner.query(`
|
||||||
|
DROP INDEX IF EXISTS idx_families_viewer_invite_code;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
DROP INDEX IF EXISTS idx_families_caregiver_invite_code;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
DROP INDEX IF EXISTS idx_families_parent_invite_code;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Drop columns
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE families
|
||||||
|
DROP COLUMN viewer_invite_expires_at,
|
||||||
|
DROP COLUMN viewer_invite_code,
|
||||||
|
DROP COLUMN caregiver_invite_expires_at,
|
||||||
|
DROP COLUMN caregiver_invite_code,
|
||||||
|
DROP COLUMN parent_invite_expires_at,
|
||||||
|
DROP COLUMN parent_invite_code;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { UserType } from './user.type';
|
|||||||
export enum FamilyRole {
|
export enum FamilyRole {
|
||||||
PARENT = 'parent',
|
PARENT = 'parent',
|
||||||
CAREGIVER = 'caregiver',
|
CAREGIVER = 'caregiver',
|
||||||
|
VIEWER = 'viewer',
|
||||||
}
|
}
|
||||||
|
|
||||||
registerEnumType(FamilyRole, {
|
registerEnumType(FamilyRole, {
|
||||||
|
|||||||
@@ -171,29 +171,9 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create default family for user
|
// REMOVED: Don't create default family during registration
|
||||||
const family = this.familyRepository.create({
|
// Family will be created during onboarding when user chooses "Create New Family"
|
||||||
name: `${registerDto.name}'s Family`,
|
// or user will join existing family if they choose "Join Family"
|
||||||
createdBy: savedUser.id,
|
|
||||||
subscriptionTier: 'free',
|
|
||||||
});
|
|
||||||
|
|
||||||
const savedFamily = await this.familyRepository.save(family);
|
|
||||||
|
|
||||||
// Add user as parent in the family
|
|
||||||
const familyMember = this.familyMemberRepository.create({
|
|
||||||
userId: savedUser.id,
|
|
||||||
familyId: savedFamily.id,
|
|
||||||
role: FamilyRole.PARENT,
|
|
||||||
permissions: {
|
|
||||||
canAddChildren: true,
|
|
||||||
canEditChildren: true,
|
|
||||||
canLogActivities: true,
|
|
||||||
canViewReports: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.familyMemberRepository.save(familyMember);
|
|
||||||
|
|
||||||
// Log COPPA consent if user is under 18
|
// Log COPPA consent if user is under 18
|
||||||
if (age < 18 && registerDto.coppaConsentGiven) {
|
if (age < 18 && registerDto.coppaConsentGiven) {
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ import { IsString, Length } from 'class-validator';
|
|||||||
|
|
||||||
export class JoinFamilyDto {
|
export class JoinFamilyDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@Length(6, 6)
|
@Length(6, 10)
|
||||||
shareCode: string;
|
shareCode: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,4 +136,132 @@ export class FamiliesController {
|
|||||||
message: 'Member removed successfully',
|
message: 'Member removed successfully',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/share-code')
|
||||||
|
async generateShareCode(@Req() req: any, @Param('id') familyId: string) {
|
||||||
|
const result = await this.familiesService.generateShareCode(
|
||||||
|
familyId,
|
||||||
|
req.user.userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate role-based invite code
|
||||||
|
* POST /api/v1/families/:id/invite-codes
|
||||||
|
* Body: { role: 'parent' | 'caregiver' | 'viewer', expiryDays?: number }
|
||||||
|
*/
|
||||||
|
@Post(':id/invite-codes')
|
||||||
|
async generateRoleInviteCode(
|
||||||
|
@Req() req: any,
|
||||||
|
@Param('id') familyId: string,
|
||||||
|
@Body() body: { role: FamilyRole; expiryDays?: number },
|
||||||
|
) {
|
||||||
|
const result = await this.familiesService.generateRoleInviteCode(
|
||||||
|
familyId,
|
||||||
|
req.user.userId,
|
||||||
|
body.role,
|
||||||
|
body.expiryDays,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active role-based invite codes for a family
|
||||||
|
* GET /api/v1/families/:id/invite-codes
|
||||||
|
*/
|
||||||
|
@Get(':id/invite-codes')
|
||||||
|
async getRoleInviteCodes(@Req() req: any, @Param('id') familyId: string) {
|
||||||
|
const codes = await this.familiesService.getRoleInviteCodes(
|
||||||
|
familyId,
|
||||||
|
req.user.userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: codes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join family using role-based invite code
|
||||||
|
* POST /api/v1/families/join-with-role
|
||||||
|
* Body: { inviteCode: string }
|
||||||
|
*/
|
||||||
|
@Post('join-with-role')
|
||||||
|
async joinFamilyWithRoleCode(
|
||||||
|
@Req() req: any,
|
||||||
|
@Body() body: { inviteCode: string },
|
||||||
|
) {
|
||||||
|
const member = await this.familiesService.joinFamilyWithRoleCode(
|
||||||
|
req.user.userId,
|
||||||
|
body.inviteCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
member,
|
||||||
|
message: 'Successfully joined family with assigned role',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a role-based invite code
|
||||||
|
* DELETE /api/v1/families/:id/invite-codes/:role
|
||||||
|
*/
|
||||||
|
@Delete(':id/invite-codes/:role')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async revokeRoleInviteCode(
|
||||||
|
@Req() req: any,
|
||||||
|
@Param('id') familyId: string,
|
||||||
|
@Param('role') role: FamilyRole,
|
||||||
|
) {
|
||||||
|
await this.familiesService.revokeRoleInviteCode(
|
||||||
|
familyId,
|
||||||
|
req.user.userId,
|
||||||
|
role,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `${role} invite code revoked successfully`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send email invite with role-based invite code
|
||||||
|
* POST /api/v1/families/:id/email-invite
|
||||||
|
* Body: { email: string, role: 'parent' | 'caregiver' | 'viewer' }
|
||||||
|
*/
|
||||||
|
@Post(':id/email-invite')
|
||||||
|
async sendEmailInvite(
|
||||||
|
@Req() req: any,
|
||||||
|
@Param('id') familyId: string,
|
||||||
|
@Body() body: { email: string; role: FamilyRole },
|
||||||
|
) {
|
||||||
|
const result = await this.familiesService.sendEmailInvite(
|
||||||
|
familyId,
|
||||||
|
req.user.userId,
|
||||||
|
body.email,
|
||||||
|
body.role,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: result.emailSent
|
||||||
|
? `Invite email sent successfully to ${body.email}`
|
||||||
|
: `Invite code generated but email could not be sent`,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { User } from '../../database/entities/user.entity';
|
import { User } from '../../database/entities/user.entity';
|
||||||
import { InviteFamilyMemberDto } from './dto/invite-family-member.dto';
|
import { InviteFamilyMemberDto } from './dto/invite-family-member.dto';
|
||||||
import { JoinFamilyDto } from './dto/join-family.dto';
|
import { JoinFamilyDto } from './dto/join-family.dto';
|
||||||
|
import { EmailService } from '../../common/services/email.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FamiliesService {
|
export class FamiliesService {
|
||||||
@@ -25,6 +26,7 @@ export class FamiliesService {
|
|||||||
private familyMemberRepository: Repository<FamilyMember>,
|
private familyMemberRepository: Repository<FamilyMember>,
|
||||||
@InjectRepository(User)
|
@InjectRepository(User)
|
||||||
private userRepository: Repository<User>,
|
private userRepository: Repository<User>,
|
||||||
|
private emailService: EmailService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async inviteMember(
|
async inviteMember(
|
||||||
@@ -101,6 +103,14 @@ export class FamiliesService {
|
|||||||
throw new NotFoundException('Invalid share code');
|
throw new NotFoundException('Invalid share code');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if code is expired
|
||||||
|
if (
|
||||||
|
family.shareCodeExpiresAt &&
|
||||||
|
family.shareCodeExpiresAt < new Date()
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('This family code has expired');
|
||||||
|
}
|
||||||
|
|
||||||
// Check if user is already a member
|
// Check if user is already a member
|
||||||
const existingMember = await this.familyMemberRepository.findOne({
|
const existingMember = await this.familyMemberRepository.findOne({
|
||||||
where: { userId, familyId: family.id },
|
where: { userId, familyId: family.id },
|
||||||
@@ -276,4 +286,412 @@ export class FamiliesService {
|
|||||||
|
|
||||||
await this.familyMemberRepository.remove(targetMember);
|
await this.familyMemberRepository.remove(targetMember);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async generateShareCode(
|
||||||
|
familyId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ code: string; expiresAt: Date }> {
|
||||||
|
// Verify user is family admin/parent
|
||||||
|
const membership = await this.familyMemberRepository.findOne({
|
||||||
|
where: { userId, familyId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership || membership.role !== FamilyRole.PARENT) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Only family admins can generate share codes',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get family
|
||||||
|
const family = await this.familyRepository.findOne({
|
||||||
|
where: { id: familyId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!family) {
|
||||||
|
throw new NotFoundException('Family not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random code (8 characters)
|
||||||
|
const code = this.generateRandomCode(8);
|
||||||
|
|
||||||
|
// Set expiration (7 days from now)
|
||||||
|
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// Update family record
|
||||||
|
family.shareCode = code;
|
||||||
|
family.shareCodeExpiresAt = expiresAt;
|
||||||
|
|
||||||
|
await this.familyRepository.save(family);
|
||||||
|
|
||||||
|
return { code, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateRandomCode(length: number): string {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
let code = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate role-based invite code
|
||||||
|
* Returns a unique invite code for a specific role with expiration
|
||||||
|
*/
|
||||||
|
async generateRoleInviteCode(
|
||||||
|
familyId: string,
|
||||||
|
userId: string,
|
||||||
|
role: FamilyRole,
|
||||||
|
expiryDays: number = 7,
|
||||||
|
): Promise<{ code: string; expiresAt: Date; role: FamilyRole }> {
|
||||||
|
// Verify user is family admin/parent
|
||||||
|
const membership = await this.familyMemberRepository.findOne({
|
||||||
|
where: { userId, familyId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership || membership.role !== FamilyRole.PARENT) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Only family admins can generate invite codes',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get family
|
||||||
|
const family = await this.familyRepository.findOne({
|
||||||
|
where: { id: familyId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!family) {
|
||||||
|
throw new NotFoundException('Family not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random code (8 characters)
|
||||||
|
const code = this.generateRandomCode(8);
|
||||||
|
|
||||||
|
// Set expiration
|
||||||
|
const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// Update family record based on role
|
||||||
|
switch (role) {
|
||||||
|
case FamilyRole.PARENT:
|
||||||
|
family.parentInviteCode = code;
|
||||||
|
family.parentInviteExpiresAt = expiresAt;
|
||||||
|
break;
|
||||||
|
case FamilyRole.CAREGIVER:
|
||||||
|
family.caregiverInviteCode = code;
|
||||||
|
family.caregiverInviteExpiresAt = expiresAt;
|
||||||
|
break;
|
||||||
|
case FamilyRole.VIEWER:
|
||||||
|
family.viewerInviteCode = code;
|
||||||
|
family.viewerInviteExpiresAt = expiresAt;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new BadRequestException('Invalid role');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.familyRepository.save(family);
|
||||||
|
|
||||||
|
return { code, expiresAt, role };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active role-based invite codes for a family
|
||||||
|
*/
|
||||||
|
async getRoleInviteCodes(
|
||||||
|
familyId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<{
|
||||||
|
parent?: { code: string; expiresAt: Date };
|
||||||
|
caregiver?: { code: string; expiresAt: Date };
|
||||||
|
viewer?: { code: string; expiresAt: Date };
|
||||||
|
}> {
|
||||||
|
// Verify user is family admin/parent
|
||||||
|
const membership = await this.familyMemberRepository.findOne({
|
||||||
|
where: { userId, familyId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership || membership.role !== FamilyRole.PARENT) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Only family admins can view invite codes',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get family
|
||||||
|
const family = await this.familyRepository.findOne({
|
||||||
|
where: { id: familyId },
|
||||||
|
select: [
|
||||||
|
'id',
|
||||||
|
'parentInviteCode',
|
||||||
|
'parentInviteExpiresAt',
|
||||||
|
'caregiverInviteCode',
|
||||||
|
'caregiverInviteExpiresAt',
|
||||||
|
'viewerInviteCode',
|
||||||
|
'viewerInviteExpiresAt',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!family) {
|
||||||
|
throw new NotFoundException('Family not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const result: {
|
||||||
|
parent?: { code: string; expiresAt: Date };
|
||||||
|
caregiver?: { code: string; expiresAt: Date };
|
||||||
|
viewer?: { code: string; expiresAt: Date };
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
// Only return codes that are not expired
|
||||||
|
if (
|
||||||
|
family.parentInviteCode &&
|
||||||
|
family.parentInviteExpiresAt &&
|
||||||
|
family.parentInviteExpiresAt > now
|
||||||
|
) {
|
||||||
|
result.parent = {
|
||||||
|
code: family.parentInviteCode,
|
||||||
|
expiresAt: family.parentInviteExpiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
family.caregiverInviteCode &&
|
||||||
|
family.caregiverInviteExpiresAt &&
|
||||||
|
family.caregiverInviteExpiresAt > now
|
||||||
|
) {
|
||||||
|
result.caregiver = {
|
||||||
|
code: family.caregiverInviteCode,
|
||||||
|
expiresAt: family.caregiverInviteExpiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
family.viewerInviteCode &&
|
||||||
|
family.viewerInviteExpiresAt &&
|
||||||
|
family.viewerInviteExpiresAt > now
|
||||||
|
) {
|
||||||
|
result.viewer = {
|
||||||
|
code: family.viewerInviteCode,
|
||||||
|
expiresAt: family.viewerInviteExpiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join family using role-based invite code
|
||||||
|
* Determines role based on which code was used
|
||||||
|
*/
|
||||||
|
async joinFamilyWithRoleCode(
|
||||||
|
userId: string,
|
||||||
|
inviteCode: string,
|
||||||
|
): Promise<FamilyMember> {
|
||||||
|
// Find family by any of the role-based invite codes
|
||||||
|
const family = await this.familyRepository
|
||||||
|
.createQueryBuilder('family')
|
||||||
|
.where('family.parentInviteCode = :code', { code: inviteCode })
|
||||||
|
.orWhere('family.caregiverInviteCode = :code', { code: inviteCode })
|
||||||
|
.orWhere('family.viewerInviteCode = :code', { code: inviteCode })
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
if (!family) {
|
||||||
|
throw new NotFoundException('Invalid invite code');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine role based on which code matched
|
||||||
|
let targetRole: FamilyRole;
|
||||||
|
let codeExpiresAt: Date | null = null;
|
||||||
|
|
||||||
|
if (family.parentInviteCode === inviteCode) {
|
||||||
|
targetRole = FamilyRole.PARENT;
|
||||||
|
codeExpiresAt = family.parentInviteExpiresAt || null;
|
||||||
|
} else if (family.caregiverInviteCode === inviteCode) {
|
||||||
|
targetRole = FamilyRole.CAREGIVER;
|
||||||
|
codeExpiresAt = family.caregiverInviteExpiresAt || null;
|
||||||
|
} else if (family.viewerInviteCode === inviteCode) {
|
||||||
|
targetRole = FamilyRole.VIEWER;
|
||||||
|
codeExpiresAt = family.viewerInviteExpiresAt || null;
|
||||||
|
} else {
|
||||||
|
throw new NotFoundException('Invalid invite code');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if code is expired
|
||||||
|
if (codeExpiresAt && codeExpiresAt < new Date()) {
|
||||||
|
throw new BadRequestException('This invite code has expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is already a member
|
||||||
|
const existingMember = await this.familyMemberRepository.findOne({
|
||||||
|
where: { userId, familyId: family.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingMember) {
|
||||||
|
throw new ConflictException('You are already a member of this family');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check family size limit
|
||||||
|
const memberCount = await this.familyMemberRepository.count({
|
||||||
|
where: { familyId: family.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (memberCount >= 10) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Family has reached maximum member limit (10)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set permissions based on role
|
||||||
|
let permissions: any;
|
||||||
|
switch (targetRole) {
|
||||||
|
case FamilyRole.PARENT:
|
||||||
|
permissions = {
|
||||||
|
canAddChildren: true,
|
||||||
|
canEditChildren: true,
|
||||||
|
canLogActivities: true,
|
||||||
|
canViewReports: true,
|
||||||
|
canInviteMembers: true,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case FamilyRole.CAREGIVER:
|
||||||
|
permissions = {
|
||||||
|
canAddChildren: false,
|
||||||
|
canEditChildren: true,
|
||||||
|
canLogActivities: true,
|
||||||
|
canViewReports: true,
|
||||||
|
canInviteMembers: false,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case FamilyRole.VIEWER:
|
||||||
|
permissions = {
|
||||||
|
canAddChildren: false,
|
||||||
|
canEditChildren: false,
|
||||||
|
canLogActivities: false,
|
||||||
|
canViewReports: true,
|
||||||
|
canInviteMembers: false,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add user as member with determined role
|
||||||
|
const newMember = this.familyMemberRepository.create({
|
||||||
|
userId,
|
||||||
|
familyId: family.id,
|
||||||
|
role: targetRole,
|
||||||
|
permissions,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.familyMemberRepository.save(newMember);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a role-based invite code
|
||||||
|
*/
|
||||||
|
async revokeRoleInviteCode(
|
||||||
|
familyId: string,
|
||||||
|
userId: string,
|
||||||
|
role: FamilyRole,
|
||||||
|
): Promise<void> {
|
||||||
|
// Verify user is family admin/parent
|
||||||
|
const membership = await this.familyMemberRepository.findOne({
|
||||||
|
where: { userId, familyId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership || membership.role !== FamilyRole.PARENT) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Only family admins can revoke invite codes',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get family
|
||||||
|
const family = await this.familyRepository.findOne({
|
||||||
|
where: { id: familyId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!family) {
|
||||||
|
throw new NotFoundException('Family not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear code based on role
|
||||||
|
switch (role) {
|
||||||
|
case FamilyRole.PARENT:
|
||||||
|
family.parentInviteCode = null;
|
||||||
|
family.parentInviteExpiresAt = null;
|
||||||
|
break;
|
||||||
|
case FamilyRole.CAREGIVER:
|
||||||
|
family.caregiverInviteCode = null;
|
||||||
|
family.caregiverInviteExpiresAt = null;
|
||||||
|
break;
|
||||||
|
case FamilyRole.VIEWER:
|
||||||
|
family.viewerInviteCode = null;
|
||||||
|
family.viewerInviteExpiresAt = null;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new BadRequestException('Invalid role');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.familyRepository.save(family);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send email invite with role-based invite code
|
||||||
|
*/
|
||||||
|
async sendEmailInvite(
|
||||||
|
familyId: string,
|
||||||
|
userId: string,
|
||||||
|
recipientEmail: string,
|
||||||
|
role: FamilyRole,
|
||||||
|
): Promise<{ code: string; expiresAt: Date; emailSent: boolean }> {
|
||||||
|
// Verify user is family admin/parent
|
||||||
|
const membership = await this.familyMemberRepository.findOne({
|
||||||
|
where: { userId, familyId },
|
||||||
|
relations: ['user'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership || membership.role !== FamilyRole.PARENT) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Only family admins can send email invites',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get family with full details
|
||||||
|
const family = await this.familyRepository.findOne({
|
||||||
|
where: { id: familyId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!family) {
|
||||||
|
throw new NotFoundException('Family not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate or get existing role-based invite code
|
||||||
|
const inviteData = await this.generateRoleInviteCode(
|
||||||
|
familyId,
|
||||||
|
userId,
|
||||||
|
role,
|
||||||
|
7, // 7 days expiry
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send email invite
|
||||||
|
let emailSent = false;
|
||||||
|
try {
|
||||||
|
await this.emailService.sendFamilyInviteEmail(
|
||||||
|
recipientEmail,
|
||||||
|
membership.user.name || 'A family member',
|
||||||
|
family.name || 'My Family',
|
||||||
|
role,
|
||||||
|
inviteData.code,
|
||||||
|
inviteData.expiresAt,
|
||||||
|
);
|
||||||
|
emailSent = true;
|
||||||
|
} catch (error) {
|
||||||
|
// Log error but don't fail the request
|
||||||
|
// The invite code is still valid even if email fails
|
||||||
|
console.error('Failed to send invite email:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: inviteData.code,
|
||||||
|
expiresAt: inviteData.expiresAt,
|
||||||
|
emailSent,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, OneToMany, JoinColumn } from 'typeorm';
|
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, OneToMany, JoinColumn } from 'typeorm';
|
||||||
import { User } from '../../database/entities/user.entity';
|
import { User } from '../../database/entities/user.entity';
|
||||||
|
import { Family } from '../../database/entities/family.entity';
|
||||||
|
import { FamilyRole } from '../../database/entities/family-member.entity';
|
||||||
|
|
||||||
@Entity('invite_codes')
|
@Entity('invite_codes')
|
||||||
export class InviteCode {
|
export class InviteCode {
|
||||||
@@ -16,8 +18,19 @@ export class InviteCode {
|
|||||||
@JoinColumn({ name: 'created_by' })
|
@JoinColumn({ name: 'created_by' })
|
||||||
creator?: User;
|
creator?: User;
|
||||||
|
|
||||||
@Column({ default: 0 })
|
// Family-specific invite codes
|
||||||
uses: number;
|
@Column({ name: 'family_id', nullable: true, length: 20 })
|
||||||
|
familyId: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => Family, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'family_id' })
|
||||||
|
family?: Family;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||||
|
role: FamilyRole | null;
|
||||||
|
|
||||||
|
@Column({ name: 'use_count', default: 0 })
|
||||||
|
useCount: number;
|
||||||
|
|
||||||
@Column({ name: 'max_uses', nullable: true })
|
@Column({ name: 'max_uses', nullable: true })
|
||||||
maxUses: number | null;
|
maxUses: number | null;
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { IsNumber, IsObject, IsOptional, Min, Max } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateOnboardingProgressDto {
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(4)
|
||||||
|
step: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
data?: {
|
||||||
|
selectedLanguage?: string;
|
||||||
|
selectedMeasurement?: 'metric' | 'imperial';
|
||||||
|
familyChoice?: 'create' | 'join';
|
||||||
|
childData?: {
|
||||||
|
name?: string;
|
||||||
|
birthDate?: string;
|
||||||
|
gender?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
NotificationPreferences,
|
NotificationPreferences,
|
||||||
} from './user-preferences.service';
|
} from './user-preferences.service';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { UpdateOnboardingProgressDto } from './dto/update-onboarding-progress.dto';
|
||||||
|
|
||||||
@Controller('api/v1/preferences')
|
@Controller('api/v1/preferences')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -114,4 +115,72 @@ export class UserPreferencesController {
|
|||||||
const userId = req.user?.userId;
|
const userId = req.user?.userId;
|
||||||
return this.userPreferencesService.resetToDefaults(userId);
|
return this.userPreferencesService.resetToDefaults(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get onboarding status
|
||||||
|
*/
|
||||||
|
@Get('onboarding')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async getOnboardingStatus(@Req() req: any): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
completed: boolean;
|
||||||
|
step: number;
|
||||||
|
data: any;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
const status = await this.userPreferencesService.getOnboardingStatus(
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update onboarding progress
|
||||||
|
*/
|
||||||
|
@Put('onboarding/progress')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async updateOnboardingProgress(
|
||||||
|
@Req() req: any,
|
||||||
|
@Body() dto: UpdateOnboardingProgressDto,
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
user: any;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
const user = await this.userPreferencesService.updateOnboardingProgress(
|
||||||
|
userId,
|
||||||
|
dto.step,
|
||||||
|
dto.data,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { user },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark onboarding as complete
|
||||||
|
*/
|
||||||
|
@Post('onboarding/complete')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async completeOnboarding(@Req() req: any): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
user: any;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
const user = await this.userPreferencesService.completeOnboarding(userId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { user },
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,4 +237,85 @@ export class UserPreferencesService {
|
|||||||
|
|
||||||
return defaultPreferences;
|
return defaultPreferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update onboarding progress for a user
|
||||||
|
*/
|
||||||
|
async updateOnboardingProgress(
|
||||||
|
userId: string,
|
||||||
|
step: number,
|
||||||
|
data?: any,
|
||||||
|
): Promise<User> {
|
||||||
|
const user = await this.userRepository.findOne({
|
||||||
|
where: { id: userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge new data with existing onboarding data
|
||||||
|
const updatedData = {
|
||||||
|
...user.onboardingData,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.userRepository.update(userId, {
|
||||||
|
onboardingStep: step,
|
||||||
|
onboardingData: updatedData as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Updated onboarding progress for user ${userId} - step ${step}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.userRepository.findOne({ where: { id: userId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark onboarding as complete for a user
|
||||||
|
*/
|
||||||
|
async completeOnboarding(userId: string): Promise<User> {
|
||||||
|
const user = await this.userRepository.findOne({
|
||||||
|
where: { id: userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.userRepository.update(userId, {
|
||||||
|
onboardingCompleted: true,
|
||||||
|
onboardingStep: 4, // Final step
|
||||||
|
onboardingData: null, // Clear temporary data
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Completed onboarding for user ${userId}`);
|
||||||
|
|
||||||
|
return this.userRepository.findOne({ where: { id: userId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get onboarding status for a user
|
||||||
|
*/
|
||||||
|
async getOnboardingStatus(userId: string): Promise<{
|
||||||
|
completed: boolean;
|
||||||
|
step: number;
|
||||||
|
data: any;
|
||||||
|
}> {
|
||||||
|
const user = await this.userRepository.findOne({
|
||||||
|
where: { id: userId },
|
||||||
|
select: ['id', 'onboardingCompleted', 'onboardingStep', 'onboardingData'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
completed: user.onboardingCompleted || false,
|
||||||
|
step: user.onboardingStep || 0,
|
||||||
|
data: user.onboardingData || {},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ type FamilyMember {
|
|||||||
enum FamilyRole {
|
enum FamilyRole {
|
||||||
CAREGIVER
|
CAREGIVER
|
||||||
PARENT
|
PARENT
|
||||||
|
VIEWER
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Gender {
|
enum Gender {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Stepper,
|
Stepper,
|
||||||
@@ -38,8 +38,10 @@ import { supportedLanguages } from '@/lib/i18n/config';
|
|||||||
import { usersApi } from '@/lib/api/users';
|
import { usersApi } from '@/lib/api/users';
|
||||||
import { useTheme } from '@mui/material/styles';
|
import { useTheme } from '@mui/material/styles';
|
||||||
import { StepIconProps } from '@mui/material/StepIcon';
|
import { StepIconProps } from '@mui/material/StepIcon';
|
||||||
|
import { FamilySetupStep, ChildData } from '@/components/onboarding/FamilySetupStep';
|
||||||
|
import { familiesApi } from '@/lib/api/families';
|
||||||
|
|
||||||
const steps = ['Welcome', 'Language', 'Measurements', 'Add Child', 'Complete'];
|
const steps = ['Welcome', 'Preferences', 'Family Setup', 'Complete'];
|
||||||
|
|
||||||
// Custom connector for mobile-friendly stepper
|
// Custom connector for mobile-friendly stepper
|
||||||
const CustomConnector = styled(StepConnector)(({ theme }) => ({
|
const CustomConnector = styled(StepConnector)(({ theme }) => ({
|
||||||
@@ -105,47 +107,66 @@ export default function OnboardingPage() {
|
|||||||
const { t } = useTranslation('onboarding');
|
const { t } = useTranslation('onboarding');
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
|
// Restore onboarding progress on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const restoreProgress = async () => {
|
||||||
|
try {
|
||||||
|
if (user?.id) {
|
||||||
|
const status = await usersApi.getOnboardingStatus();
|
||||||
|
|
||||||
|
// If onboarding is completed, redirect to home
|
||||||
|
if (status.completed) {
|
||||||
|
router.push('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user has saved progress, restore it
|
||||||
|
if (status.step > 0) {
|
||||||
|
setActiveStep(status.step);
|
||||||
|
|
||||||
|
if (status.data?.selectedLanguage) {
|
||||||
|
setSelectedLanguage(status.data.selectedLanguage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.data?.selectedMeasurement) {
|
||||||
|
setSelectedMeasurement(status.data.selectedMeasurement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to restore onboarding progress:', err);
|
||||||
|
// Continue with default state if restore fails
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
restoreProgress();
|
||||||
|
}, [user?.id, router]);
|
||||||
|
|
||||||
const handleNext = async () => {
|
const handleNext = async () => {
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
// Step 1: Save language preference
|
// Step 1: Save preferences (language + measurement)
|
||||||
if (activeStep === 1) {
|
if (activeStep === 1) {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await setLanguage(selectedLanguage);
|
await setLanguage(selectedLanguage);
|
||||||
// Save to backend
|
|
||||||
if (user?.id) {
|
|
||||||
await usersApi.updatePreferences({
|
|
||||||
language: selectedLanguage,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setActiveStep((prevActiveStep) => prevActiveStep + 1);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Failed to save language:', err);
|
|
||||||
// Language was saved locally even if backend failed, so continue
|
|
||||||
setActiveStep((prevActiveStep) => prevActiveStep + 1);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Save measurement preference
|
|
||||||
if (activeStep === 2) {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setMeasurementSystem(selectedMeasurement);
|
setMeasurementSystem(selectedMeasurement);
|
||||||
// Save to backend
|
|
||||||
|
// Save progress to backend
|
||||||
if (user?.id) {
|
if (user?.id) {
|
||||||
await usersApi.updatePreferences({
|
await usersApi.updateOnboardingProgress({
|
||||||
measurementUnit: selectedMeasurement,
|
step: activeStep + 1,
|
||||||
|
data: {
|
||||||
|
selectedLanguage,
|
||||||
|
selectedMeasurement,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await refreshUser();
|
await refreshUser();
|
||||||
}
|
}
|
||||||
setActiveStep((prevActiveStep) => prevActiveStep + 1);
|
setActiveStep((prevActiveStep) => prevActiveStep + 1);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to save measurement:', err);
|
console.error('Failed to save preferences:', err);
|
||||||
// Measurement was saved locally even if backend failed, so continue
|
// Preferences were saved locally even if backend failed, so continue
|
||||||
setActiveStep((prevActiveStep) => prevActiveStep + 1);
|
setActiveStep((prevActiveStep) => prevActiveStep + 1);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -153,41 +174,27 @@ export default function OnboardingPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Validate and save child data (Add Child)
|
// Step 2: Family Setup - This is now handled by FamilySetupStep component
|
||||||
if (activeStep === 3) {
|
if (activeStep === 2) {
|
||||||
if (!childName.trim() || !childBirthDate) {
|
// The component handles the logic internally via onCreateFamily or onJoinFamily
|
||||||
setError(t('child.name') + ' and ' + t('child.dateOfBirth') + ' are required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const familyId = user?.families?.[0]?.familyId;
|
|
||||||
if (!familyId) {
|
|
||||||
console.error('No family found. User object:', JSON.stringify(user, null, 2));
|
|
||||||
setError('Unable to create child profile. Your account setup is incomplete. Please contact support or try logging out and back in.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
await childrenApi.createChild(familyId, {
|
|
||||||
name: childName.trim(),
|
|
||||||
birthDate: childBirthDate,
|
|
||||||
gender: childGender,
|
|
||||||
});
|
|
||||||
setActiveStep((prevActiveStep) => prevActiveStep + 1);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Failed to create child:', err);
|
|
||||||
setError(err.response?.data?.message || 'Failed to save child. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeStep === steps.length - 1) {
|
if (activeStep === steps.length - 1) {
|
||||||
// Complete onboarding
|
// Complete onboarding
|
||||||
router.push('/');
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
if (user?.id) {
|
||||||
|
await usersApi.completeOnboarding();
|
||||||
|
}
|
||||||
|
router.push('/');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to mark onboarding complete:', err);
|
||||||
|
// Navigate anyway since onboarding flow is done
|
||||||
|
router.push('/');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setActiveStep((prevActiveStep) => prevActiveStep + 1);
|
setActiveStep((prevActiveStep) => prevActiveStep + 1);
|
||||||
}
|
}
|
||||||
@@ -201,6 +208,50 @@ export default function OnboardingPage() {
|
|||||||
router.push('/');
|
router.push('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreateFamily = async (childData: ChildData) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
// Create a new family for the user
|
||||||
|
const createdFamily = await familiesApi.createFamily({
|
||||||
|
name: `${user?.name || 'My'}'s Family`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await refreshUser(); // Refresh to get new family membership
|
||||||
|
|
||||||
|
// Add the first child to the new family
|
||||||
|
await childrenApi.createChild(createdFamily.id, {
|
||||||
|
name: childData.name.trim(),
|
||||||
|
birthDate: childData.birthDate,
|
||||||
|
gender: childData.gender,
|
||||||
|
});
|
||||||
|
|
||||||
|
setActiveStep((prev) => prev + 1);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to create family:', err);
|
||||||
|
setError(err.response?.data?.message || 'Failed to create family. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoinFamily = async (shareCode: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
await familiesApi.joinFamily({ shareCode });
|
||||||
|
await refreshUser(); // Refresh to get new family membership
|
||||||
|
setActiveStep((prev) => prev + 1);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to join family:', err);
|
||||||
|
setError(err.response?.data?.message || 'Failed to join family. Please check the code and try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -235,7 +286,7 @@ export default function OnboardingPage() {
|
|||||||
if (index === 0) stepLabel = t('welcome.title').split('!')[0];
|
if (index === 0) stepLabel = t('welcome.title').split('!')[0];
|
||||||
else if (index === 1) stepLabel = t('language.title');
|
else if (index === 1) stepLabel = t('language.title');
|
||||||
else if (index === 2) stepLabel = t('measurements.title');
|
else if (index === 2) stepLabel = t('measurements.title');
|
||||||
else if (index === 3) stepLabel = t('child.title');
|
else if (index === 3) stepLabel = 'Family Setup';
|
||||||
else if (index === 4) stepLabel = t('complete.title').split('!')[0];
|
else if (index === 4) stepLabel = t('complete.title').split('!')[0];
|
||||||
|
|
||||||
// Only show label for active step on mobile
|
// Only show label for active step on mobile
|
||||||
@@ -294,165 +345,14 @@ export default function OnboardingPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 1: Language Selection */}
|
{/* Step 1: Preferences (Language + Measurements) */}
|
||||||
{activeStep === 1 && (
|
{activeStep === 1 && (
|
||||||
<Box sx={{ py: 4 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
|
||||||
<Language sx={{ fontSize: 40, color: 'primary.main', mr: 2 }} />
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h5" fontWeight="600">
|
|
||||||
{t('language.title')}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{t('language.subtitle')}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert severity="error" sx={{ mb: 2, borderRadius: 2 }}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Grid container spacing={2} sx={{ mt: 2 }}>
|
|
||||||
{supportedLanguages.map((lang) => (
|
|
||||||
<Grid item xs={12} sm={6} key={lang.code}>
|
|
||||||
<Card
|
|
||||||
sx={{
|
|
||||||
border: selectedLanguage === lang.code ? '2px solid' : '1px solid',
|
|
||||||
borderColor: selectedLanguage === lang.code ? 'primary.main' : 'divider',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CardActionArea
|
|
||||||
onClick={() => setSelectedLanguage(lang.code)}
|
|
||||||
sx={{ p: 2 }}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h6" fontWeight="600">
|
|
||||||
{lang.nativeName}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{lang.name}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Radio
|
|
||||||
checked={selectedLanguage === lang.code}
|
|
||||||
value={lang.code}
|
|
||||||
name="language-radio"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</CardActionArea>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Alert severity="info" sx={{ mt: 3, borderRadius: 2 }}>
|
|
||||||
{t('language.description')}
|
|
||||||
</Alert>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 2: Measurement System */}
|
|
||||||
{activeStep === 2 && (
|
|
||||||
<Box sx={{ py: 4 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
|
||||||
<Straighten sx={{ fontSize: 40, color: 'primary.main', mr: 2 }} />
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h5" fontWeight="600">
|
|
||||||
{t('measurements.title')}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{t('measurements.subtitle')}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert severity="error" sx={{ mb: 2, borderRadius: 2 }}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Grid container spacing={3} sx={{ mt: 2 }}>
|
|
||||||
<Grid item xs={12} sm={6}>
|
|
||||||
<Card
|
|
||||||
sx={{
|
|
||||||
border: selectedMeasurement === 'metric' ? '2px solid' : '1px solid',
|
|
||||||
borderColor: selectedMeasurement === 'metric' ? 'primary.main' : 'divider',
|
|
||||||
height: '100%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CardActionArea
|
|
||||||
onClick={() => setSelectedMeasurement('metric')}
|
|
||||||
sx={{ p: 3, height: '100%' }}
|
|
||||||
>
|
|
||||||
<Box sx={{ textAlign: 'center' }}>
|
|
||||||
<Radio
|
|
||||||
checked={selectedMeasurement === 'metric'}
|
|
||||||
value="metric"
|
|
||||||
name="measurement-radio"
|
|
||||||
sx={{ mb: 1 }}
|
|
||||||
/>
|
|
||||||
<Typography variant="h6" fontWeight="600" gutterBottom>
|
|
||||||
{t('measurements.metric.title')}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{t('measurements.metric.description')}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</CardActionArea>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid item xs={12} sm={6}>
|
|
||||||
<Card
|
|
||||||
sx={{
|
|
||||||
border: selectedMeasurement === 'imperial' ? '2px solid' : '1px solid',
|
|
||||||
borderColor: selectedMeasurement === 'imperial' ? 'primary.main' : 'divider',
|
|
||||||
height: '100%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CardActionArea
|
|
||||||
onClick={() => setSelectedMeasurement('imperial')}
|
|
||||||
sx={{ p: 3, height: '100%' }}
|
|
||||||
>
|
|
||||||
<Box sx={{ textAlign: 'center' }}>
|
|
||||||
<Radio
|
|
||||||
checked={selectedMeasurement === 'imperial'}
|
|
||||||
value="imperial"
|
|
||||||
name="measurement-radio"
|
|
||||||
sx={{ mb: 1 }}
|
|
||||||
/>
|
|
||||||
<Typography variant="h6" fontWeight="600" gutterBottom>
|
|
||||||
{t('measurements.imperial.title')}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{t('measurements.imperial.description')}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</CardActionArea>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Alert severity="info" sx={{ mt: 3, borderRadius: 2 }}>
|
|
||||||
{t('measurements.description')}
|
|
||||||
</Alert>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 3: Add Child */}
|
|
||||||
{activeStep === 3 && (
|
|
||||||
<Box sx={{ py: 4 }}>
|
<Box sx={{ py: 4 }}>
|
||||||
<Typography variant="h5" gutterBottom fontWeight="600">
|
<Typography variant="h5" gutterBottom fontWeight="600">
|
||||||
{t('child.title')}
|
{t('preferences.title') || 'Preferences'}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
{t('child.subtitle')}
|
{t('preferences.subtitle') || 'Set your language and measurement preferences'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -461,61 +361,59 @@ export default function OnboardingPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Language Dropdown */}
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
|
||||||
label={t('child.name')}
|
|
||||||
value={childName}
|
|
||||||
onChange={(e) => setChildName(e.target.value)}
|
|
||||||
margin="normal"
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
InputProps={{
|
|
||||||
sx: { borderRadius: 3 },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label={t('child.dateOfBirth')}
|
|
||||||
type="date"
|
|
||||||
value={childBirthDate}
|
|
||||||
onChange={(e) => setChildBirthDate(e.target.value)}
|
|
||||||
margin="normal"
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
InputLabelProps={{
|
|
||||||
shrink: true,
|
|
||||||
}}
|
|
||||||
InputProps={{
|
|
||||||
sx: { borderRadius: 3 },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
select
|
select
|
||||||
label={t('child.gender')}
|
fullWidth
|
||||||
value={childGender}
|
label={t('language.title') || 'Language'}
|
||||||
onChange={(e) => setChildGender(e.target.value as 'male' | 'female' | 'other')}
|
value={selectedLanguage}
|
||||||
|
onChange={(e) => setSelectedLanguage(e.target.value)}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
disabled={loading}
|
sx={{ mb: 2 }}
|
||||||
InputProps={{
|
|
||||||
sx: { borderRadius: 3 },
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<MenuItem value="male">{t('child.genders.male')}</MenuItem>
|
{supportedLanguages.map((lang) => (
|
||||||
<MenuItem value="female">{t('child.genders.female')}</MenuItem>
|
<MenuItem key={lang.code} value={lang.code}>
|
||||||
<MenuItem value="other">{t('child.genders.preferNotToSay')}</MenuItem>
|
{lang.nativeName} ({lang.name})
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
{/* Measurement Dropdown */}
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
fullWidth
|
||||||
|
label={t('measurements.title') || 'Measurement Units'}
|
||||||
|
value={selectedMeasurement}
|
||||||
|
onChange={(e) => setSelectedMeasurement(e.target.value as 'metric' | 'imperial')}
|
||||||
|
margin="normal"
|
||||||
|
>
|
||||||
|
<MenuItem value="metric">
|
||||||
|
{t('measurements.metric.title') || 'Metric'} (kg, cm, °C)
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem value="imperial">
|
||||||
|
{t('measurements.imperial.title') || 'Imperial'} (lbs, inches, °F)
|
||||||
|
</MenuItem>
|
||||||
</TextField>
|
</TextField>
|
||||||
|
|
||||||
<Alert severity="info" sx={{ mt: 3, borderRadius: 2 }}>
|
<Alert severity="info" sx={{ mt: 3, borderRadius: 2 }}>
|
||||||
{t('child.skipForNow')}
|
{t('preferences.description') || 'You can change these settings later in your profile.'}
|
||||||
</Alert>
|
</Alert>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 4: Complete */}
|
{/* Step 2: Family Setup */}
|
||||||
{activeStep === 4 && (
|
{activeStep === 2 && (
|
||||||
|
<FamilySetupStep
|
||||||
|
onCreateFamily={handleCreateFamily}
|
||||||
|
onJoinFamily={handleJoinFamily}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Complete */}
|
||||||
|
{activeStep === 3 && (
|
||||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { familiesApi, Family, FamilyMember, InviteMemberData, JoinFamilyData } f
|
|||||||
import { InviteMemberDialog } from '@/components/family/InviteMemberDialog';
|
import { InviteMemberDialog } from '@/components/family/InviteMemberDialog';
|
||||||
import { JoinFamilyDialog } from '@/components/family/JoinFamilyDialog';
|
import { JoinFamilyDialog } from '@/components/family/JoinFamilyDialog';
|
||||||
import { RemoveMemberDialog } from '@/components/family/RemoveMemberDialog';
|
import { RemoveMemberDialog } from '@/components/family/RemoveMemberDialog';
|
||||||
|
import { RoleInvitesSection } from '@/components/family/RoleInvitesSection';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { useSelectedFamily } from '@/hooks/useSelectedFamily';
|
import { useSelectedFamily } from '@/hooks/useSelectedFamily';
|
||||||
@@ -427,6 +428,17 @@ export default function FamilyPage() {
|
|||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{/* Role-Based Invites - Only visible to parents */}
|
||||||
|
{isParent && family && (
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<RoleInvitesSection
|
||||||
|
familyId={familyId!}
|
||||||
|
onSuccess={(message) => setSnackbar({ open: true, message })}
|
||||||
|
onError={(message) => setError(message)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
367
maternal-web/components/family/RoleInvitesSection.tsx
Normal file
367
maternal-web/components/family/RoleInvitesSection.tsx
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Paper,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
TextField,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
|
Accordion,
|
||||||
|
AccordionSummary,
|
||||||
|
AccordionDetails,
|
||||||
|
Stack,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
ContentCopy,
|
||||||
|
Refresh,
|
||||||
|
Delete,
|
||||||
|
Email,
|
||||||
|
ExpandMore,
|
||||||
|
Info,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { familiesApi } from '@/lib/api/families';
|
||||||
|
|
||||||
|
interface RoleInvitesSectionProps {
|
||||||
|
familyId: string;
|
||||||
|
onSuccess?: (message: string) => void;
|
||||||
|
onError?: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoleCode {
|
||||||
|
code: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoleCodes {
|
||||||
|
parent?: RoleCode;
|
||||||
|
caregiver?: RoleCode;
|
||||||
|
viewer?: RoleCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleInfo = {
|
||||||
|
parent: {
|
||||||
|
icon: '👨👩👧',
|
||||||
|
label: 'Parent',
|
||||||
|
description: 'Full access - manage children, log activities, view reports, and invite others',
|
||||||
|
color: 'primary' as const,
|
||||||
|
},
|
||||||
|
caregiver: {
|
||||||
|
icon: '🤝',
|
||||||
|
label: 'Caregiver',
|
||||||
|
description: 'Can edit children, log activities, and view reports (cannot add children or invite others)',
|
||||||
|
color: 'secondary' as const,
|
||||||
|
},
|
||||||
|
viewer: {
|
||||||
|
icon: '👁️',
|
||||||
|
label: 'Viewer',
|
||||||
|
description: 'View-only access - can view reports but cannot log activities',
|
||||||
|
color: 'info' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RoleInvitesSection({ familyId, onSuccess, onError }: RoleInvitesSectionProps) {
|
||||||
|
const [roleCodes, setRoleCodes] = useState<RoleCodes>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
const [emailDialogOpen, setEmailDialogOpen] = useState(false);
|
||||||
|
const [selectedRole, setSelectedRole] = useState<'parent' | 'caregiver' | 'viewer' | null>(null);
|
||||||
|
const [recipientEmail, setRecipientEmail] = useState('');
|
||||||
|
const [emailSending, setEmailSending] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRoleCodes();
|
||||||
|
}, [familyId]);
|
||||||
|
|
||||||
|
const fetchRoleCodes = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const codes = await familiesApi.getRoleInviteCodes(familyId);
|
||||||
|
setRoleCodes(codes);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to fetch role codes:', err);
|
||||||
|
onError?.('Failed to load invite codes');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateCode = async (role: 'parent' | 'caregiver' | 'viewer') => {
|
||||||
|
try {
|
||||||
|
setActionLoading(role);
|
||||||
|
const result = await familiesApi.generateRoleInviteCode(familyId, role, 7);
|
||||||
|
setRoleCodes(prev => ({
|
||||||
|
...prev,
|
||||||
|
[role]: { code: result.code, expiresAt: result.expiresAt },
|
||||||
|
}));
|
||||||
|
onSuccess?.(`${roleInfo[role].label} invite code generated`);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to generate code:', err);
|
||||||
|
onError?.('Failed to generate invite code');
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyCode = async (code: string, role: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(code);
|
||||||
|
onSuccess?.(`${roleInfo[role as keyof typeof roleInfo].label} code copied to clipboard`);
|
||||||
|
} catch (err) {
|
||||||
|
onError?.('Failed to copy code');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevokeCode = async (role: 'parent' | 'caregiver' | 'viewer') => {
|
||||||
|
try {
|
||||||
|
setActionLoading(role);
|
||||||
|
await familiesApi.revokeRoleInviteCode(familyId, role);
|
||||||
|
setRoleCodes(prev => {
|
||||||
|
const updated = { ...prev };
|
||||||
|
delete updated[role];
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
onSuccess?.(`${roleInfo[role].label} invite code revoked`);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to revoke code:', err);
|
||||||
|
onError?.('Failed to revoke invite code');
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEmailDialog = (role: 'parent' | 'caregiver' | 'viewer') => {
|
||||||
|
setSelectedRole(role);
|
||||||
|
setRecipientEmail('');
|
||||||
|
setEmailDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendEmailInvite = async () => {
|
||||||
|
if (!selectedRole || !recipientEmail) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setEmailSending(true);
|
||||||
|
const result = await familiesApi.sendEmailInvite(familyId, recipientEmail, selectedRole);
|
||||||
|
|
||||||
|
// Update the codes
|
||||||
|
setRoleCodes(prev => ({
|
||||||
|
...prev,
|
||||||
|
[selectedRole]: { code: result.code, expiresAt: result.expiresAt },
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (result.emailSent) {
|
||||||
|
onSuccess?.(`Invite email sent to ${recipientEmail}`);
|
||||||
|
} else {
|
||||||
|
onSuccess?.('Invite code generated, but email could not be sent');
|
||||||
|
}
|
||||||
|
|
||||||
|
setEmailDialogOpen(false);
|
||||||
|
setRecipientEmail('');
|
||||||
|
setSelectedRole(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to send email invite:', err);
|
||||||
|
onError?.(err.response?.data?.message || 'Failed to send email invite');
|
||||||
|
} finally {
|
||||||
|
setEmailSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatExpiryDate = (expiresAt: Date) => {
|
||||||
|
const date = new Date(expiresAt);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = date.getTime() - now.getTime();
|
||||||
|
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffDays < 0) return 'Expired';
|
||||||
|
if (diffDays === 0) return 'Expires today';
|
||||||
|
if (diffDays === 1) return 'Expires tomorrow';
|
||||||
|
return `Expires in ${diffDays} days`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRoleCodeCard = (role: 'parent' | 'caregiver' | 'viewer') => {
|
||||||
|
const info = roleInfo[role];
|
||||||
|
const codeData = roleCodes[role];
|
||||||
|
const isLoading = actionLoading === role;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Accordion key={role} sx={{ mb: 2, borderRadius: 2, '&:before': { display: 'none' } }}>
|
||||||
|
<AccordionSummary
|
||||||
|
expandIcon={<ExpandMore />}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
'&.Mui-expanded': { borderBottomLeftRadius: 0, borderBottomRightRadius: 0 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
|
||||||
|
<Typography sx={{ fontSize: '1.5rem' }}>{info.icon}</Typography>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="subtitle1" fontWeight="600">
|
||||||
|
{info.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{codeData ? formatExpiryDate(codeData.expiresAt) : 'No active code'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{codeData && (
|
||||||
|
<Chip
|
||||||
|
label={codeData.code}
|
||||||
|
color={info.color}
|
||||||
|
sx={{ fontWeight: 600, letterSpacing: 1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
{info.description}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{codeData ? (
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<ContentCopy />}
|
||||||
|
onClick={() => handleCopyCode(codeData.code, role)}
|
||||||
|
disabled={isLoading}
|
||||||
|
sx={{ textTransform: 'none', borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
Copy Code
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<Email />}
|
||||||
|
onClick={() => handleOpenEmailDialog(role)}
|
||||||
|
disabled={isLoading}
|
||||||
|
sx={{ textTransform: 'none', borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
Email Invite
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<Refresh />}
|
||||||
|
onClick={() => handleGenerateCode(role)}
|
||||||
|
disabled={isLoading}
|
||||||
|
sx={{ textTransform: 'none', borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
Regenerate
|
||||||
|
</Button>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => handleRevokeCode(role)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Delete fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color={info.color}
|
||||||
|
onClick={() => handleGenerateCode(role)}
|
||||||
|
disabled={isLoading}
|
||||||
|
startIcon={isLoading ? <CircularProgress size={16} /> : null}
|
||||||
|
sx={{ textTransform: 'none', borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
Generate {info.label} Invite Code
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Paper elevation={0} sx={{ p: 3, borderRadius: 3, bgcolor: 'background.paper' }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Paper elevation={0} sx={{ p: 3, borderRadius: 3, bgcolor: 'background.paper' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
|
<Typography variant="h6" fontWeight="600">
|
||||||
|
Role-Based Invites
|
||||||
|
</Typography>
|
||||||
|
<Tooltip title="Generate invite codes with specific roles and permissions">
|
||||||
|
<IconButton size="small">
|
||||||
|
<Info fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Alert severity="info" sx={{ mb: 3, borderRadius: 2 }}>
|
||||||
|
Create role-specific invite codes that automatically assign permissions when used.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
{renderRoleCodeCard('parent')}
|
||||||
|
{renderRoleCodeCard('caregiver')}
|
||||||
|
{renderRoleCodeCard('viewer')}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Email Invite Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={emailDialogOpen}
|
||||||
|
onClose={() => setEmailDialogOpen(false)}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
Send {selectedRole && roleInfo[selectedRole].icon} {selectedRole && roleInfo[selectedRole].label} Invite via Email
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
Send an invitation email with the {selectedRole} invite code
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Recipient Email"
|
||||||
|
type="email"
|
||||||
|
value={recipientEmail}
|
||||||
|
onChange={(e) => setRecipientEmail(e.target.value)}
|
||||||
|
placeholder="name@example.com"
|
||||||
|
autoFocus
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setEmailDialogOpen(false)} sx={{ textTransform: 'none' }}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSendEmailInvite}
|
||||||
|
variant="contained"
|
||||||
|
disabled={!recipientEmail || emailSending}
|
||||||
|
startIcon={emailSending ? <CircularProgress size={16} /> : <Email />}
|
||||||
|
sx={{ textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
Send Invite
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
251
maternal-web/components/onboarding/FamilySetupStep.tsx
Normal file
251
maternal-web/components/onboarding/FamilySetupStep.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
CardActionArea,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Alert,
|
||||||
|
MenuItem,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { FamilyRestroom, PersonAdd } from '@mui/icons-material';
|
||||||
|
|
||||||
|
export interface ChildData {
|
||||||
|
name: string;
|
||||||
|
birthDate: string;
|
||||||
|
gender: 'male' | 'female' | 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FamilySetupStepProps {
|
||||||
|
onCreateFamily: (childData: ChildData) => Promise<void>;
|
||||||
|
onJoinFamily: (shareCode: string) => Promise<void>;
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FamilySetupStep({
|
||||||
|
onCreateFamily,
|
||||||
|
onJoinFamily,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
t,
|
||||||
|
}: FamilySetupStepProps) {
|
||||||
|
const [choice, setChoice] = useState<'create' | 'join' | null>(null);
|
||||||
|
const [shareCode, setShareCode] = useState('');
|
||||||
|
const [childName, setChildName] = useState('');
|
||||||
|
const [childBirthDate, setChildBirthDate] = useState('');
|
||||||
|
const [childGender, setChildGender] = useState<'male' | 'female' | 'other'>('other');
|
||||||
|
|
||||||
|
if (choice === 'create') {
|
||||||
|
return (
|
||||||
|
<Box sx={{ py: 4 }}>
|
||||||
|
<Typography variant="h5" gutterBottom fontWeight="600">
|
||||||
|
{t('child.title')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
{t('child.subtitle')}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2, borderRadius: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={t('child.name')}
|
||||||
|
value={childName}
|
||||||
|
onChange={(e) => setChildName(e.target.value)}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
InputProps={{
|
||||||
|
sx: { borderRadius: 3 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={t('child.dateOfBirth')}
|
||||||
|
type="date"
|
||||||
|
value={childBirthDate}
|
||||||
|
onChange={(e) => setChildBirthDate(e.target.value)}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
InputLabelProps={{
|
||||||
|
shrink: true,
|
||||||
|
}}
|
||||||
|
InputProps={{
|
||||||
|
sx: { borderRadius: 3 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
select
|
||||||
|
label={t('child.gender')}
|
||||||
|
value={childGender}
|
||||||
|
onChange={(e) => setChildGender(e.target.value as 'male' | 'female' | 'other')}
|
||||||
|
margin="normal"
|
||||||
|
disabled={loading}
|
||||||
|
InputProps={{
|
||||||
|
sx: { borderRadius: 3 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="male">{t('child.genders.male')}</MenuItem>
|
||||||
|
<MenuItem value="female">{t('child.genders.female')}</MenuItem>
|
||||||
|
<MenuItem value="other">{t('child.genders.preferNotToSay')}</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, mt: 3 }}>
|
||||||
|
<Button onClick={() => setChoice(null)} disabled={loading}>
|
||||||
|
{t('navigation.back')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => {
|
||||||
|
if (!childName.trim() || !childBirthDate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onCreateFamily({ name: childName, birthDate: childBirthDate, gender: childGender });
|
||||||
|
}}
|
||||||
|
disabled={loading || !childName.trim() || !childBirthDate}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Create Family
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (choice === 'join') {
|
||||||
|
return (
|
||||||
|
<Box sx={{ py: 4 }}>
|
||||||
|
<Typography variant="h5" gutterBottom fontWeight="600">
|
||||||
|
Join Existing Family
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
Enter the family code to join an existing family
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2, borderRadius: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Family Code"
|
||||||
|
value={shareCode}
|
||||||
|
onChange={(e) => setShareCode(e.target.value.toUpperCase())}
|
||||||
|
margin="normal"
|
||||||
|
placeholder="ABC123DEF"
|
||||||
|
helperText="Ask your family admin for the code"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
InputProps={{
|
||||||
|
sx: { borderRadius: 3 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert severity="info" sx={{ mt: 2, borderRadius: 2 }}>
|
||||||
|
You'll be added as a viewer by default. The admin can change your role later.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, mt: 3 }}>
|
||||||
|
<Button onClick={() => setChoice(null)} disabled={loading}>
|
||||||
|
{t('navigation.back')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => onJoinFamily(shareCode)}
|
||||||
|
disabled={loading || !shareCode}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Join Family
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ py: 4 }}>
|
||||||
|
<Typography variant="h5" gutterBottom align="center" fontWeight="600">
|
||||||
|
Choose Your Setup
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" align="center" sx={{ mb: 4 }}>
|
||||||
|
Start tracking or join an existing family
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2, borderRadius: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'translateY(-4px)',
|
||||||
|
boxShadow: 4,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardActionArea onClick={() => setChoice('create')} sx={{ p: 2 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<FamilyRestroom fontSize="large" color="primary" />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" fontWeight="600">
|
||||||
|
Create New Family
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Start tracking for your child. Invite family members later.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</CardActionArea>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'translateY(-4px)',
|
||||||
|
boxShadow: 4,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardActionArea onClick={() => setChoice('join')} sx={{ p: 2 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<PersonAdd fontSize="large" color="primary" />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" fontWeight="600">
|
||||||
|
Join Existing Family
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Enter family code to join. Access shared tracking.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</CardActionArea>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,6 +32,12 @@ export interface JoinFamilyData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const familiesApi = {
|
export const familiesApi = {
|
||||||
|
// Create a new family
|
||||||
|
createFamily: async (data: { name: string }): Promise<Family> => {
|
||||||
|
const response = await apiClient.post('/api/v1/families', data);
|
||||||
|
return response.data.data.family;
|
||||||
|
},
|
||||||
|
|
||||||
// Get a specific family
|
// Get a specific family
|
||||||
getFamily: async (familyId: string): Promise<Family> => {
|
getFamily: async (familyId: string): Promise<Family> => {
|
||||||
const response = await apiClient.get(`/api/v1/families/${familyId}`);
|
const response = await apiClient.get(`/api/v1/families/${familyId}`);
|
||||||
@@ -66,4 +72,57 @@ export const familiesApi = {
|
|||||||
removeMember: async (familyId: string, userId: string): Promise<void> => {
|
removeMember: async (familyId: string, userId: string): Promise<void> => {
|
||||||
await apiClient.delete(`/api/v1/families/${familyId}/members/${userId}`);
|
await apiClient.delete(`/api/v1/families/${familyId}/members/${userId}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Generate a new share code for the family
|
||||||
|
generateShareCode: async (familyId: string): Promise<{ code: string; expiresAt: Date }> => {
|
||||||
|
const response = await apiClient.post(`/api/v1/families/${familyId}/share-code`);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Generate role-based invite code
|
||||||
|
generateRoleInviteCode: async (
|
||||||
|
familyId: string,
|
||||||
|
role: 'parent' | 'caregiver' | 'viewer',
|
||||||
|
expiryDays?: number
|
||||||
|
): Promise<{ code: string; expiresAt: Date; role: string }> => {
|
||||||
|
const response = await apiClient.post(`/api/v1/families/${familyId}/invite-codes`, {
|
||||||
|
role,
|
||||||
|
expiryDays,
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get all active role-based invite codes
|
||||||
|
getRoleInviteCodes: async (familyId: string): Promise<{
|
||||||
|
parent?: { code: string; expiresAt: Date };
|
||||||
|
caregiver?: { code: string; expiresAt: Date };
|
||||||
|
viewer?: { code: string; expiresAt: Date };
|
||||||
|
}> => {
|
||||||
|
const response = await apiClient.get(`/api/v1/families/${familyId}/invite-codes`);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Join family with role-based invite code
|
||||||
|
joinFamilyWithRoleCode: async (inviteCode: string): Promise<FamilyMember> => {
|
||||||
|
const response = await apiClient.post('/api/v1/families/join-with-role', { inviteCode });
|
||||||
|
return response.data.data.member;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Revoke a role-based invite code
|
||||||
|
revokeRoleInviteCode: async (familyId: string, role: 'parent' | 'caregiver' | 'viewer'): Promise<void> => {
|
||||||
|
await apiClient.delete(`/api/v1/families/${familyId}/invite-codes/${role}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Send email invite with role-based code
|
||||||
|
sendEmailInvite: async (
|
||||||
|
familyId: string,
|
||||||
|
email: string,
|
||||||
|
role: 'parent' | 'caregiver' | 'viewer'
|
||||||
|
): Promise<{ code: string; expiresAt: Date; emailSent: boolean }> => {
|
||||||
|
const response = await apiClient.post(`/api/v1/families/${familyId}/email-invite`, {
|
||||||
|
email,
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,26 @@ export interface UserProfile {
|
|||||||
emailVerified: boolean;
|
emailVerified: boolean;
|
||||||
preferences?: UserPreferences;
|
preferences?: UserPreferences;
|
||||||
families?: string[];
|
families?: string[];
|
||||||
|
onboardingCompleted?: boolean;
|
||||||
|
onboardingStep?: number;
|
||||||
|
onboardingData?: OnboardingData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnboardingData {
|
||||||
|
selectedLanguage?: string;
|
||||||
|
selectedMeasurement?: 'metric' | 'imperial';
|
||||||
|
familyChoice?: 'create' | 'join';
|
||||||
|
childData?: {
|
||||||
|
name?: string;
|
||||||
|
birthDate?: string;
|
||||||
|
gender?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnboardingStatus {
|
||||||
|
completed: boolean;
|
||||||
|
step: number;
|
||||||
|
data: OnboardingData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usersApi = {
|
export const usersApi = {
|
||||||
@@ -31,4 +51,22 @@ export const usersApi = {
|
|||||||
const response = await apiClient.patch('/api/v1/auth/profile', data);
|
const response = await apiClient.patch('/api/v1/auth/profile', data);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Get onboarding status
|
||||||
|
getOnboardingStatus: async (): Promise<OnboardingStatus> => {
|
||||||
|
const response = await apiClient.get('/api/v1/preferences/onboarding');
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update onboarding progress
|
||||||
|
updateOnboardingProgress: async (data: { step: number; data?: OnboardingData }): Promise<UserProfile> => {
|
||||||
|
const response = await apiClient.put('/api/v1/preferences/onboarding/progress', data);
|
||||||
|
return response.data.data.user;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mark onboarding as complete
|
||||||
|
completeOnboarding: async (): Promise<UserProfile> => {
|
||||||
|
const response = await apiClient.post('/api/v1/preferences/onboarding/complete');
|
||||||
|
return response.data.data.user;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user