Home Under the hood

Under the hood

How CHRIS works internally
By Ilija Ćurić
5 articles

Architecture

Understanding the system design and component structure of CHRIS. Overview CHRIS follows a modern JAMstack architecture with React on the frontend and Supabase providing backend services. ┌─────────────────────────────────────────────────────────┐ │ Frontend (React) │ ├─────────────────────────────────────────────────────────┤ │ Pages │ Components │ Hooks │ Contexts │ Utils │ └─────────────────────────────────────────────────────────┘ │ │ HTTPS ▼ ┌─────────────────────────────────────────────────────────┐ │ Supabase Platform │ ├──────────────┬──────────────┬──────────────┬────────────┤ │ PostgreSQL │ Auth │ Storage │ Functions │ │ Database │ (GoTrue) │ (S3) │ (Deno) │ └──────────────┴──────────────┴──────────────┴────────────┘ Frontend Architecture Technology Stack | Layer | Technology | Purpose | |-------|------------|---------| | Framework | React 18 | UI rendering | | Language | TypeScript | Type safety | | Build | Vite | Fast development/builds | | UI Components | shadcn/ui | Pre-built components | | Styling | Tailwind CSS | Utility-first CSS | | State | TanStack Query | Server state management | | Routing | React Router v6 | Client-side routing | | Forms | React Hook Form + Zod | Form handling/validation | Directory Structure src/ ├── components/ # Reusable UI components │ ├── Contracts/ # Contract-related components │ ├── Dashboard/ # Dashboard widgets │ ├── Layout/ # App layout (nav, sidebar) │ ├── Profile/ # Profile-related components │ └── ui/ # shadcn/ui base components ├── contexts/ # React contexts │ └── LanguageContext.tsx ├── hooks/ # Custom React hooks │ ├── useAuth.tsx │ ├── useTeamLeadership.tsx │ └── use-toast.ts ├── integrations/ # External service integrations │ └── supabase/ │ ├── client.ts # Supabase client instance │ └── types.ts # Auto-generated types ├── pages/ # Route components │ ├── Auth.tsx │ ├── Dashboard.tsx │ ├── Employees.tsx │ ├── LeaveRequests.tsx │ ├── Profile.tsx │ ├── Settings.tsx │ └── Teams.tsx └── utils/ # Utility functions └── emailNotifications.ts Component Design Page Components Pages are route-level components that: - Handle data fetching with TanStack Query - Compose smaller components - Manage page-level state // Example: LeaveRequests.tsx function LeaveRequests() { const { data: requests, isLoading } = useQuery({ queryKey: ['leave-requests'], queryFn: fetchLeaveRequests }); if (isLoading) return <LoadingSpinner />; return ( <Layout> <LeaveRequestList requests={requests} /> </Layout> ); } Feature Components Feature-specific components organized by domain: components/ ├── Contracts/ │ ├── ContractForm.tsx │ ├── ContractList.tsx │ └── ContractCard.tsx ├── Dashboard/ │ ├── LeaveBalanceCard.tsx │ ├── PendingRequestsCard.tsx │ └── TeamCalendar.tsx UI Components Base components from shadcn/ui, customized for CHRIS: components/ui/ ├── button.tsx ├── card.tsx ├── dialog.tsx ├── form.tsx ├── input.tsx ├── select.tsx └── table.tsx State Management Server State (TanStack Query) All server data is managed via TanStack Query: // Fetching data const { data, isLoading, error } = useQuery({ queryKey: ['profiles'], queryFn: () => supabase.from('profiles').select('*') }); // Mutations const mutation = useMutation({ mutationFn: (newRequest) => supabase.from('leave_requests').insert(newRequest), onSuccess: () => { queryClient.invalidateQueries(['leave-requests']); } }); Client State (Contexts) Global client state via React Context: - LanguageContext: Current language, translations - AuthContext: User session, role // Using language context const { t, language, setLanguage } = useLanguage(); // Using auth context const { user, isAdmin, isHR } = useAuth(); Backend Architecture Supabase Services | Service | Purpose | |---------|---------| | PostgreSQL | Relational database | | Auth (GoTrue) | User authentication | | Storage | File storage (avatars, CVs) | | Edge Functions | Server-side logic | | Realtime | Live subscriptions (optional) | Database Design The database follows these principles: 1. Normalized structure - Minimal data duplication 2. UUID primary keys - Globally unique identifiers 3. Timestamps - created_at, updated_at on all tables 4. Soft deletes - Status fields instead of deletion Row Level Security (RLS) All tables have RLS policies enforcing access control: -- Example: Users can only see their own leave requests CREATE POLICY "Users can view own requests" ON leave_requests FOR SELECT USING (auth.uid() = user_id); -- HR can see all requests CREATE POLICY "HR can view all requests" ON leave_requests FOR SELECT USING ( EXISTS ( SELECT 1 FROM user_roles WHERE user_id = auth.uid() AND role IN ('admin', 'hr_manager') ) ); Data Flow Read Operations User Action → React Component → TanStack Query → Supabase Client ↓ Database ← RLS Policy Check ← PostgREST ← Supabase ↓ Response → Cache Update → Re-render → UI Update Write Operations User Action → Form Submit → Validation (Zod) → Mutation ↓ Supabase Client → Edge Function (if needed) → Database ↓ Success → Cache Invalidation → Re-fetch → UI Update Email Notifications User Action → Frontend → Edge Function → SMTP Server → Recipient │ └─→ Database (company_settings for SMTP config) Key Design Decisions Why Supabase? - PostgreSQL: Powerful relational database with JSON support - Built-in Auth: Secure authentication without custom code - Auto-generated API: PostgREST provides instant REST API - Edge Functions: Server-side logic when needed - Real-time: Optional live updates Why shadcn/ui? - Customizable: Copy components into your project - Accessible: Built on Radix UI primitives - Tailwind: Integrates with existing styling - No lock-in: You own the component code Why TanStack Query? - Caching: Automatic request deduplication - Background updates: Fresh data without blocking UI - Optimistic updates: Instant UI feedback - DevTools: Easy debugging Deployment Architecture Production Setup ┌─────────────────┐ ┌─────────────────┐ │ CDN (Vercel) │ │ Supabase │ │ │ │ Platform │ │ Static Assets │────▶│ - Database │ │ React App │ │ - Auth │ │ │ │ - Functions │ └─────────────────┘ └─────────────────┘ Scaling Considerations | Component | Scaling Strategy | |-----------|------------------| | Frontend | CDN edge caching | | Database | Supabase handles scaling | | Auth | Supabase handles scaling | | Storage | S3-compatible, unlimited | | Functions | Auto-scaling Deno Deploy | Security Architecture Authentication Flow 1. User submits credentials 2. Supabase Auth validates 3. JWT token issued 4. Token sent with all requests 5. RLS policies enforce access Data Protection - Transport: HTTPS everywhere - At rest: Encrypted database storage - Secrets: Stored in Supabase vault - Passwords: bcrypt hashed

Last updated on Jun 12, 2026

Email Workflow

Understanding how email notifications work in CHRIS. Overview CHRIS sends automated email notifications for key events: - Leave request submissions - Leave request approvals/rejections - Welcome emails for new employees - Password reset requests Architecture ┌─────────────┐ ┌──────────────────┐ ┌─────────────┐ │ Frontend │────▶│ Edge Function │────▶│ SMTP Server │ │ │ │ (Deno Runtime) │ │ │ └─────────────┘ └────────┬─────────┘ └──────┬──────┘ │ │ ▼ ▼ ┌─────────────────┐ ┌─────────────┐ │ company_settings│ │ Recipient │ │ (SMTP config) │ │ Inbox │ └─────────────────┘ └─────────────┘ Email Types Leave Request Submitted Trigger: Employee submits a new leave request Recipients: - Team leader (if assigned) - HR managers Content: - Employee name and email - Leave type (Annual, Sick, etc.) - Date range - Working days requested - Reason (if provided) Template Variables: {{employee_name}} {{employee_email}} {{leave_type}} {{start_date}} {{end_date}} {{working_days}} {{reason}} Leave Request Status Update Trigger: Request is approved or rejected Recipients: - The employee who submitted the request Content: - Decision (Approved/Rejected) - Approver's name - Comments (if any) - Original request details Template Variables: {{employee_name}} {{status}} {{approver_name}} {{approver_comment}} {{leave_type}} {{start_date}} {{end_date}} {{working_days}} Welcome Email Trigger: New employee created Recipients: - The new employee Content: - Welcome message - Login instructions - Getting started guide Template Variables: {{employee_name}} {{email}} {{position}} {{start_date}} Password Reset Trigger: Admin initiates password reset Recipients: - The employee Content: - Reset link (time-limited) - Instructions SMTP Configuration Settings Location SMTP settings are stored in company_settings: | Field | Description | |-------|-------------| | smtp_host | Server address (e.g., smtp.gmail.com) | | smtp_port | Port number (usually 587) | | smtp_username | Authentication email | | smtp_password | SMTP password (encrypted) | | smtp_from_email | Sender email address | | smtp_from_name | Sender display name | | smtp_use_tls | Enable TLS encryption | | smtp_enabled | Master enable/disable | Provider Examples === "Gmail" ``` Host: smtp.gmail.com Port: 587 TLS: Enabled Username: your-email@gmail.com Password: App-specific password ``` === "Outlook" ``` Host: smtp-mail.outlook.com Port: 587 TLS: Enabled Username: your-email@outlook.com Password: App-specific password ``` === "Custom SMTP" Consult your provider's documentation for correct settings. Email Flow Sending an Email // src/utils/emailNotifications.ts export async function sendNotificationEmail({ to, subject, html, text }: EmailParams) { const { data, error } = await supabase.functions.invoke( 'send-notification-email', { body: { to, subject, html, text } } ); if (error) { console.error('Email failed:', error); // Don't throw - email failure shouldn't block operations } return { success: !error }; } Edge Function Processing // supabase/functions/send-notification-email/index.ts Deno.serve(async (req) => { // 1. Verify JWT const token = req.headers.get('Authorization')?.replace('Bearer ', ''); const { data: { user } } = await supabase.auth.getUser(token); if (!user) return new Response('Unauthorized', { status: 401 }); // 2. Get request body const { to, subject, html, text } = await req.json(); // 3. Get SMTP settings const { data: settings } = await supabase .from('company_settings') .select('*') .single(); if (!settings.smtp_enabled) { return new Response(JSON.stringify({ success: false, error: 'SMTP not enabled' })); } // 4. Check for rerouting let recipients = to; if (settings.email_reroute_enabled) { recipients = [settings.email_reroute_addresses]; } // 5. Send via SMTP await sendViaSMTP(settings, recipients, subject, html, text); return new Response(JSON.stringify({ success: true })); }); Email Templates Template Storage Templates stored in email_templates table: CREATE TABLE email_templates ( id UUID PRIMARY KEY, template_key TEXT UNIQUE NOT NULL, name TEXT NOT NULL, subject TEXT NOT NULL, html_content TEXT NOT NULL, description TEXT, variables JSONB, is_active BOOLEAN DEFAULT true ); Template Example <!-- leave_request_approved --> <div style="font-family: Arial, sans-serif; max-width: 600px;"> <h2>Leave Request Approved</h2> <p>Dear {{employee_name}},</p> <p>Your leave request has been <strong>approved</strong>.</p> <table style="border-collapse: collapse; width: 100%;"> <tr> <td style="padding: 8px; border: 1px solid #ddd;">Leave Type</td> <td style="padding: 8px; border: 1px solid #ddd;">{{leave_type}}</td> </tr> <tr> <td style="padding: 8px; border: 1px solid #ddd;">Dates</td> <td style="padding: 8px; border: 1px solid #ddd;"> {{start_date}} - {{end_date}} </td> </tr> <tr> <td style="padding: 8px; border: 1px solid #ddd;">Working Days</td> <td style="padding: 8px; border: 1px solid #ddd;">{{working_days}}</td> </tr> </table> <p>Approved by: {{approver_name}}</p> {{#if approver_comment}} <p>Comment: {{approver_comment}}</p> {{/if}} <p>Best regards,<br>HR Team</p> </div> Variable Substitution function renderTemplate( template: string, variables: Record<string, string> ): string { return template.replace( /\{\{(\w+)\}\}/g, (match, key) => variables[key] || match ); } Email Rerouting Purpose Redirect all emails to a test address during: - Development - Testing - Training Configuration UPDATE company_settings SET email_reroute_enabled = true, email_reroute_addresses = 'testing@company.com'; Behavior When enabled: - Original recipients are ignored - All emails go to reroute address - Subject prefixed with "[REROUTED]" - Body includes original recipients Error Handling Non-Blocking Design Email failures don't block the main operation: async function submitLeaveRequest(request: LeaveRequest) { // 1. Save to database (critical) const { data, error } = await supabase .from('leave_requests') .insert(request); if (error) throw error; // This blocks // 2. Send notification (non-critical) try { await sendLeaveRequestNotification(data); } catch (emailError) { console.error('Email notification failed:', emailError); // Don't throw - request was saved successfully } return data; } Common Failures | Error | Cause | Solution | |-------|-------|----------| | Authentication failed | Wrong credentials | Check username/password | | Connection timeout | Wrong host/port | Verify SMTP settings | | TLS error | TLS mismatch | Toggle TLS setting | | Rate limited | Too many emails | Wait or upgrade plan | | Spam blocked | Content flagged | Review email content | Monitoring Edge Function Logs View email logs in Supabase Dashboard: 1. Go to Edge Functions 2. Select send-notification-email 3. Click Logs Log Contents { "timestamp": "2025-01-15T10:00:00Z", "status": "success", "recipients": ["user@company.com"], "subject": "Leave Request Approved", "rerouted": false } Debugging Enable verbose logging in Edge Function: console.log('SMTP Config:', { host: settings.smtp_host, port: settings.smtp_port, tls: settings.smtp_use_tls });

Last updated on Jun 12, 2026

Internationalization

Understanding the multi-language system in CHRIS. Overview CHRIS supports four languages: | Language | Code | Flag | Direction | |----------|------|------|-----------| | Croatian | hr | 🇭🇷 | LTR | | English | en | 🇬🇧 | LTR | | Russian | ru | 🇷🇺 | LTR | | Hindi | hi | 🇮🇳 | LTR | Architecture Database-Driven Translations Unlike file-based i18n, CHRIS stores translations in the database: CREATE TABLE translations ( id UUID PRIMARY KEY, key TEXT UNIQUE NOT NULL, croatian TEXT NOT NULL, english TEXT NOT NULL, russian TEXT, hindi TEXT, category TEXT, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); Advantages: - Edit translations without code changes - Non-developers can manage translations - Runtime updates without deployment LanguageContext Context Provider The LanguageContext provides translation functions: // src/contexts/LanguageContext.tsx const LanguageContext = createContext<{ language: string; setLanguage: (lang: string) => void; t: (key: string) => string; formatDate: (date: Date) => string; }>({...}); Using the Context import { useLanguage } from '@/contexts/LanguageContext'; function MyComponent() { const { t, language, setLanguage } = useLanguage(); return ( <div> <h1>{t('common.welcome')}</h1> <button onClick={() => setLanguage('hr')}> 🇭🇷 Hrvatski </button> </div> ); } Translation Function Basic Usage // Returns translated string t('common.save') // "Save" (en) or "Spremi" (hr) Key Structure Keys follow the pattern: category.descriptiveName common.save leaveRequests.submitRequest employees.addEmployee settings.smtpHost Fallback Chain If a translation is missing: 1. Try requested language (e.g., Hindi) ↓ (if null) 2. Try English ↓ (if null) 3. Try Croatian ↓ (if null) 4. Return the key itself Implementation: function t(key: string): string { const translation = translations.find(t => t.key === key); if (!translation) return key; const langMap = { hr: 'croatian', en: 'english', ru: 'russian', hi: 'hindi' }; return ( translation[langMap[language]] || translation.english || translation.croatian || key ); } Translation Categories Translations are organized by category: | Category | Description | |----------|-------------| | common | Shared UI elements | | auth | Authentication | | employees | Employee management | | leaveRequests | Leave workflow | | teams | Team management | | settings | Settings pages | | holidays | Holiday management | | contracts | Contract management | | audit | Audit logging | | profile | User profile | Querying by Category SELECT key, english, croatian FROM translations WHERE category = 'common' ORDER BY key; Date Formatting Locale-Aware Dates const { formatDate } = useLanguage(); // Returns date in current locale format formatDate(new Date('2025-01-15')) // "15.01.2025" (hr) or "01/15/2025" (en) Implementation function formatDate(date: Date): string { const locales = { hr: 'hr-HR', en: 'en-US', ru: 'ru-RU', hi: 'hi-IN' }; return date.toLocaleDateString(locales[language], { year: 'numeric', month: '2-digit', day: '2-digit' }); } Language Picker UI Component function LanguagePicker() { const { language, setLanguage } = useLanguage(); const languages = [ { code: 'hr', flag: '🇭🇷', name: 'Hrvatski' }, { code: 'en', flag: '🇬🇧', name: 'English' }, { code: 'ru', flag: '🇷🇺', name: 'Русский' }, { code: 'hi', flag: '🇮🇳', name: 'हिन्दी' }, ]; return ( <Select value={language} onValueChange={setLanguage}> {languages.map(lang => ( <SelectItem key={lang.code} value={lang.code}> {lang.flag} {lang.name} </SelectItem> ))} </Select> ); } Language Persistence Selected language is stored in localStorage: // On change localStorage.setItem('preferred-language', newLanguage); // On load const saved = localStorage.getItem('preferred-language'); setLanguage(saved || 'hr'); // Default to Croatian Adding Translations Via Database Migration INSERT INTO translations (key, croatian, english, russian, hindi, category) VALUES ('common.newFeature', 'Nova značajka', 'New Feature', 'Новая функция', 'नई सुविधा', 'common') ON CONFLICT (key) DO UPDATE SET croatian = EXCLUDED.croatian, english = EXCLUDED.english, russian = EXCLUDED.russian, hindi = EXCLUDED.hindi; Via Admin UI 1. Navigate to Settings > Translations 2. Click Add Translation 3. Enter key and translations for each language 4. Save Best Practices Key Naming ✓ common.save (good) ✓ leaveRequests.submit (good) ✗ save (too generic) ✗ saveButton (avoid 'Button') ✗ COMMON_SAVE (wrong format) Translation Quality 1. Be consistent: Use same term for same concept 2. Consider context: "Save" vs "Save Changes" 3. Watch text length: Some languages are longer 4. Use native speakers: For accuracy Missing Translations Check for missing translations: -- Find keys missing Russian SELECT key FROM translations WHERE russian IS NULL OR russian = ''; -- Find keys missing Hindi SELECT key FROM translations WHERE hindi IS NULL OR hindi = ''; Adding a New Language Step 1: Database Schema ALTER TABLE translations ADD COLUMN german TEXT; Step 2: Update Types // src/integrations/supabase/types.ts translations: { Row: { // ...existing columns german: string | null; } } Step 3: Update LanguageContext const langMap = { hr: 'croatian', en: 'english', ru: 'russian', hi: 'hindi', de: 'german', // Add new language }; const languages = [ // ...existing { code: 'de', flag: '🇩🇪', name: 'Deutsch' }, ]; Step 4: Add Translations Bulk update all translation keys with German text. Performance Caching Strategy Translations are fetched once and cached: const { data: translations } = useQuery({ queryKey: ['translations'], queryFn: fetchTranslations, staleTime: Infinity, // Never refetch automatically }); Cache Invalidation Force refresh when translations change: queryClient.invalidateQueries(['translations']); Bundle Size Since translations are database-driven, they don't increase JavaScript bundle size.

Last updated on Jun 12, 2026

Leave Balance Calculation

Understanding how leave balances are calculated in CHRIS. Overview Leave balance represents the number of days an employee can take off. The calculation considers: - Entitlement: Days granted per year - Used: Days already taken - Carryover: Days from previous year - Pending: Days in approved but future requests Balance Formula Available Balance = Entitlement + Carryover - Used - Pending Where: | Component | Description | |-----------|-------------| | Entitlement | Annual leave days from contract | | Carryover | Days carried from last year | | Used | Days from approved past requests | | Pending | Days from approved future requests | Example Calculation Employee: John Doe - Contract entitlement: 20 days/year - Carryover from 2024: 3 days - Leave taken in 2025: 8 days - Approved future leave: 2 days Available = 20 + 3 - 8 - 2 = 13 days Entitlement Sources Contract-Based Entitlement Primary source from the contracts table: SELECT yearly_leave_days FROM contracts WHERE employee_id = 'user-uuid' AND (end_date IS NULL OR end_date >= CURRENT_DATE) ORDER BY start_date DESC LIMIT 1; Profile Override Can be overridden in the profiles table: SELECT annual_entitlement_days FROM profiles WHERE id = 'user-uuid'; If annual_entitlement_days is set, it takes precedence over contract value. Calculation Priority 1. Check profiles.annual_entitlement_days 2. If null, use contracts.yearly_leave_days 3. If no contract, default to 0 Carryover Days What is Carryover? Days not used in the previous year that can be used in the current year. Carryover Limits Common policies: | Policy | Description | |--------|-------------| | No carryover | Use it or lose it | | Limited | Max X days can carry (e.g., 5) | | Full carryover | All unused days carry | | Time-limited | Must use by specific date | Carryover Storage Stored in profiles.carryover_days: UPDATE profiles SET carryover_days = 5 WHERE id = 'user-uuid'; Annual Carryover Process At year end: 1. Calculate unused days from current year 2. Apply carryover policy (limits, expiry) 3. Update carryover_days for new year 4. Reset for new entitlement period Used Days Calculation Counting Used Days Sum of working days from approved requests in the past: SELECT COALESCE(SUM(working_days), 0) as used_days FROM leave_requests WHERE user_id = 'user-uuid' AND status = 'approved' AND leave_type_id = 'annual-leave-type-uuid' AND start_date <= CURRENT_DATE AND EXTRACT(YEAR FROM start_date) = EXTRACT(YEAR FROM CURRENT_DATE); Leave Type Separation Each leave type has its own balance: - Annual leave: 20 days entitlement - Sick leave: Unlimited (or capped) - Family leave: Separate allocation Working Days Calculation What Counts as a Working Day? Working days exclude: - Weekends: Saturday and Sunday - Public holidays: From assigned holiday scheme Calculation Example Leave request: Monday Dec 22 - Friday Dec 26, 2025 | Day | Date | Working Day? | Reason | |-----|------|--------------|--------| | Mon | 22 | Yes | Regular workday | | Tue | 23 | Yes | Regular workday | | Wed | 24 | Yes | Regular workday | | Thu | 25 | No | Christmas Day | | Fri | 26 | No | St. Stephen's Day | Working days: 3 (not 5 calendar days) Implementation function calculateWorkingDays( startDate: Date, endDate: Date, holidays: Date[] ): number { let workingDays = 0; const current = new Date(startDate); while (current <= endDate) { const dayOfWeek = current.getDay(); const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; const isHoliday = holidays.some(h => h.toDateString() === current.toDateString() ); if (!isWeekend && !isHoliday) { workingDays++; } current.setDate(current.getDate() + 1); } return workingDays; } Balance by Leave Type Tracking Different Types Each leave type can have different rules: | Leave Type | Entitlement | Carryover | Tracking | |------------|-------------|-----------|----------| | Annual | 20 days | Yes | Strict balance | | Sick (Short) | Unlimited | No | Count only | | Sick (Long) | Unlimited | No | Requires docs | | Family | 5 days | No | Separate pool | Query by Type SELECT lt.name_en as leave_type, COALESCE(SUM(lr.working_days), 0) as days_used FROM leave_types lt LEFT JOIN leave_requests lr ON lt.id = lr.leave_type_id AND lr.user_id = 'user-uuid' AND lr.status = 'approved' AND EXTRACT(YEAR FROM lr.start_date) = 2025 GROUP BY lt.id, lt.name_en; Balance Display Dashboard Widget function LeaveBalanceCard() { const { data: balance } = useQuery({ queryKey: ['leave-balance'], queryFn: fetchLeaveBalance }); return ( <Card> <CardHeader>Leave Balance</CardHeader> <CardContent> <div className="grid grid-cols-2 gap-4"> <div> <Label>Annual Leave</Label> <Value>{balance.annual.available} / {balance.annual.total}</Value> </div> <div> <Label>Sick Leave</Label> <Value>{balance.sick.used} used</Value> </div> </div> </CardContent> </Card> ); } Insufficient Balance Warning When submitting a request that exceeds available balance: if (requestedDays > availableBalance) { return ( <Alert variant="warning"> <AlertTitle>Insufficient Balance</AlertTitle> <AlertDescription> You are requesting {requestedDays} days but only have {availableBalance} available. </AlertDescription> </Alert> ); } Pro-Rata Calculations Mid-Year Starters Employees starting mid-year get proportional entitlement: Pro-rata = (Annual Entitlement × Months Remaining) ÷ 12 Example: 20 days entitlement, starts July 1 Pro-rata = (20 × 6) ÷ 12 = 10 days Implementation function calculateProRataEntitlement( annualDays: number, startDate: Date, yearEnd: Date ): number { const monthsRemaining = differenceInMonths(yearEnd, startDate) + 1; return Math.round((annualDays * monthsRemaining) / 12); } Balance Adjustments Manual Adjustments HR can manually adjust balances: 1. Add days: Bonus leave, correction 2. Remove days: Correction, policy enforcement 3. Adjust carryover: Year-end processing Audit Trail All adjustments are logged: INSERT INTO audit_logs ( actor_user_id, entity_type, entity_id, action, old_values, new_values ) VALUES ( 'hr-user-uuid', 'profiles', 'employee-uuid', 'update', '{"carryover_days": 3}', '{"carryover_days": 5}' );

Last updated on Jun 12, 2026

Security Model

Understanding authentication, authorization, and security in CHRIS. Overview CHRIS implements multiple layers of security: 1. Authentication - Verifying user identity (Supabase Auth) 2. Authorization - Controlling access (RBAC + RLS) 3. Data Protection - Securing data at rest and in transit Authentication Supabase Auth (GoTrue) CHRIS uses Supabase Auth for user management: - Email/password authentication - Secure session management - JWT token-based API access - Password reset functionality Authentication Flow sequenceDiagram participant User participant Frontend participant Supabase Auth participant Database User->>Frontend: Enter credentials Frontend->>Supabase Auth: signInWithPassword() Supabase Auth->>Supabase Auth: Validate credentials Supabase Auth-->>Frontend: JWT Token + Refresh Token Frontend->>Database: API request + JWT Database->>Database: Validate JWT Database->>Database: Apply RLS policies Database-->>Frontend: Authorized data JWT Tokens Every authenticated request includes a JWT containing: { "sub": "user-uuid", "email": "user@company.com", "role": "authenticated", "exp": 1234567890 } Tokens expire after 1 hour and are automatically refreshed. Authorization (RBAC) Role-Based Access Control Three primary roles with hierarchical permissions: | Role | Level | Description | |------|-------|-------------| | admin | 3 | Full system access | | hr_manager | 2 | HR operations | | employee | 1 | Basic access | Role Storage Roles are stored in the user_roles table: CREATE TABLE user_roles ( id UUID PRIMARY KEY, user_id UUID REFERENCES auth.users, role app_role NOT NULL, created_at TIMESTAMP DEFAULT NOW() ); Role Checking Frontend role checking via useAuth hook: const { isAdmin, isHR, isEmployee } = useAuth(); if (isAdmin) { // Show admin features } Backend role checking via RPC functions: SELECT get_user_role(auth.uid()); -- Returns: 'admin', 'hr_manager', or 'employee' Row Level Security (RLS) What is RLS? Row Level Security enforces access control at the database level. Every query is automatically filtered based on policies. Policy Structure CREATE POLICY "policy_name" ON table_name FOR [SELECT | INSERT | UPDATE | DELETE | ALL] [TO role_name] USING (condition) -- For SELECT/UPDATE/DELETE WITH CHECK (condition); -- For INSERT/UPDATE Example Policies Users can view their own profile: CREATE POLICY "own_profile_select" ON profiles FOR SELECT USING (auth.uid() = id); HR can view all profiles: CREATE POLICY "hr_all_profiles_select" ON profiles FOR SELECT USING ( EXISTS ( SELECT 1 FROM user_roles WHERE user_id = auth.uid() AND role IN ('admin', 'hr_manager') ) ); Team leaders can view team requests: CREATE POLICY "team_leader_requests" ON leave_requests FOR SELECT USING ( -- Own requests auth.uid() = user_id OR -- Team member requests EXISTS ( SELECT 1 FROM profiles p JOIN teams t ON p.team_id = t.id WHERE p.id = leave_requests.user_id AND t.lead_user_id = auth.uid() ) ); RLS Best Practices 1. Enable RLS on all tables: ALTER TABLE name ENABLE ROW LEVEL SECURITY; 2. Deny by default: No access without explicit policy 3. Keep policies simple: Complex policies slow queries 4. Test thoroughly: Verify policies work as expected Admin Masquerade How It Works Admins can view the system as any user without knowing their password: 1. Admin initiates masquerade 2. Frontend switches to "view as" mode 3. All data fetched is filtered for target user 4. Actions are still logged as admin Security Considerations - Audit logging: All masquerade sessions logged - No authentication bypass: Admin doesn't get user's password - Limited scope: Can only view, not authenticate as user - Admin only: HR managers cannot masquerade Implementation // Store original user const originalUser = currentUser; // Set masquerade target setMasqueradeUser(targetUserId); // Queries now filter for target user const { data } = useQuery({ queryKey: ['profile', masqueradeUser || currentUser], queryFn: () => fetchProfile(masqueradeUser || currentUser) }); Data Protection Encryption in Transit - All connections use HTTPS/TLS - Supabase enforces TLS 1.2+ - API keys transmitted securely Encryption at Rest - Database encrypted by Supabase - Storage files encrypted - Backups encrypted Sensitive Data Handling | Data Type | Protection | |-----------|------------| | Passwords | bcrypt hashed | | SMTP credentials | Stored encrypted | | JWT tokens | Short-lived, signed | | Session data | HttpOnly cookies | API Security Request Authentication Every API request must include: Authorization: Bearer <JWT_TOKEN> apikey: <SUPABASE_ANON_KEY> Edge Function Security Edge Functions verify JWT before processing: const authHeader = req.headers.get('Authorization'); const token = authHeader?.replace('Bearer ', ''); const { data: { user }, error } = await supabase.auth.getUser(token); if (error || !user) { return new Response('Unauthorized', { status: 401 }); } Rate Limiting Supabase provides built-in rate limiting: - Auth endpoints: Limited requests per IP - API endpoints: Based on plan limits Security Best Practices For Developers 1. Never expose service role key - Only use anon key in frontend 2. Validate all input - Use Zod for form validation 3. Use prepared statements - Supabase client handles this 4. Keep dependencies updated - Regular npm updates For Administrators 1. Limit admin accounts - Minimal admin access 2. Review audit logs - Monitor for suspicious activity 3. Use strong passwords - Enforce password policies 4. Enable 2FA - When available (Supabase feature) For Users 1. Don't share credentials - Each user has own account 2. Log out on shared devices - Clear sessions 3. Report suspicious activity - Contact admin Audit Logging What's Logged | Event | Logged Data | |-------|-------------| | Profile changes | Old/new values | | Leave decisions | Approver, timestamp | | Role changes | Who, when, what | | Login attempts | Success/failure, IP | Audit Table Structure CREATE TABLE audit_logs ( id UUID PRIMARY KEY, actor_user_id UUID NOT NULL, entity_type TEXT NOT NULL, entity_id UUID NOT NULL, action TEXT NOT NULL, old_values JSONB, new_values JSONB, created_at TIMESTAMP DEFAULT NOW() ); Querying Audit Logs -- Recent changes by user SELECT * FROM audit_logs WHERE actor_user_id = 'user-uuid' ORDER BY created_at DESC LIMIT 50; -- Changes to specific record SELECT * FROM audit_logs WHERE entity_type = 'profiles' AND entity_id = 'profile-uuid';

Last updated on Jun 12, 2026