Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
H
hrsystem
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Administrator
hrsystem
Commits
f9cd0fe6
Commit
f9cd0fe6
authored
Apr 01, 2026
by
Administrator
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Update 22 files via Son of Anton
parent
84e833db
Changes
22
Hide whitespace changes
Inline
Side-by-side
Showing
22 changed files
with
2534 additions
and
0 deletions
+2534
-0
app.module.ts
backend/src/app.module.ts
+4
-0
checklist.service.ts
backend/src/modules/onboarding/checklist.service.ts
+132
-0
checklist-item.dto.ts
backend/src/modules/onboarding/dto/checklist-item.dto.ts
+9
-0
contract-sign.dto.ts
backend/src/modules/onboarding/dto/contract-sign.dto.ts
+24
-0
create-invite.dto.ts
backend/src/modules/onboarding/dto/create-invite.dto.ts
+25
-0
register.dto.ts
backend/src/modules/onboarding/dto/register.dto.ts
+82
-0
schedule-config.dto.ts
backend/src/modules/onboarding/dto/schedule-config.dto.ts
+10
-0
self-assessment.dto.ts
backend/src/modules/onboarding/dto/self-assessment.dto.ts
+22
-0
invite.service.ts
backend/src/modules/onboarding/invite.service.ts
+144
-0
onboarding.controller.ts
backend/src/modules/onboarding/onboarding.controller.ts
+143
-0
onboarding.module.ts
backend/src/modules/onboarding/onboarding.module.ts
+13
-0
onboarding.service.ts
backend/src/modules/onboarding/onboarding.service.ts
+381
-0
salary-calculator.service.ts
backend/src/modules/onboarding/salary-calculator.service.ts
+83
-0
create-user.dto.ts
backend/src/modules/users/dto/create-user.dto.ts
+118
-0
reset-password.dto.ts
backend/src/modules/users/dto/reset-password.dto.ts
+31
-0
update-user.dto.ts
backend/src/modules/users/dto/update-user.dto.ts
+151
-0
user-filter.dto.ts
backend/src/modules/users/dto/user-filter.dto.ts
+24
-0
user-response.dto.ts
backend/src/modules/users/dto/user-response.dto.ts
+55
-0
users.controller.ts
backend/src/modules/users/users.controller.ts
+151
-0
users.module.ts
backend/src/modules/users/users.module.ts
+10
-0
users.service.ts
backend/src/modules/users/users.service.ts
+776
-0
schema-additions.prisma
prisma/schema-additions.prisma
+146
-0
No files found.
backend/src/app.module.ts
View file @
f9cd0fe6
...
...
@@ -15,6 +15,8 @@ import { PrismaModule } from './prisma/prisma.module';
import
{
AuthModule
}
from
'./modules/auth/auth.module'
;
import
{
SettingsModule
}
from
'./modules/settings/settings.module'
;
import
{
AuditTrailModule
}
from
'./modules/audit-trail/audit-trail.module'
;
import
{
UsersModule
}
from
'./modules/users/users.module'
;
import
{
OnboardingModule
}
from
'./modules/onboarding/onboarding.module'
;
import
{
JwtAuthGuard
}
from
'./common/guards/jwt-auth.guard'
;
import
{
RolesGuard
}
from
'./common/guards/roles.guard'
;
...
...
@@ -36,6 +38,8 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
AuthModule
,
SettingsModule
,
AuditTrailModule
,
UsersModule
,
OnboardingModule
,
],
providers
:
[
{
provide
:
APP_GUARD
,
useClass
:
JwtAuthGuard
},
...
...
backend/src/modules/onboarding/checklist.service.ts
0 → 100644
View file @
f9cd0fe6
import
{
Injectable
,
NotFoundException
,
Logger
}
from
'@nestjs/common'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
export
interface
ChecklistItem
{
key
:
string
;
label
:
string
;
completed
:
boolean
;
completedAt
:
string
|
null
;
verificationMethod
:
'AUTOMATIC'
|
'MANUAL'
;
verifiedBy
:
string
|
null
;
}
const
DEFAULT_CHECKLIST_ITEMS
:
Array
<
Omit
<
ChecklistItem
,
'completed'
|
'completedAt'
|
'verifiedBy'
>>
=
[
{
key
:
'profile_photo'
,
label
:
'Profile photo uploaded'
,
verificationMethod
:
'AUTOMATIC'
},
{
key
:
'bank_details'
,
label
:
'Bank details provided'
,
verificationMethod
:
'AUTOMATIC'
},
{
key
:
'contract_signed'
,
label
:
'Contract signed'
,
verificationMethod
:
'AUTOMATIC'
},
{
key
:
'policies_acknowledged'
,
label
:
'All policies acknowledged'
,
verificationMethod
:
'AUTOMATIC'
},
{
key
:
'self_assessment'
,
label
:
'Competency self-assessment completed'
,
verificationMethod
:
'AUTOMATIC'
},
{
key
:
'device_setup'
,
label
:
'Device setup confirmed'
,
verificationMethod
:
'MANUAL'
},
{
key
:
'source_control'
,
label
:
'Source control access configured'
,
verificationMethod
:
'MANUAL'
},
{
key
:
'first_board'
,
label
:
'First board assigned'
,
verificationMethod
:
'MANUAL'
},
{
key
:
'intro_meeting'
,
label
:
'Introduction meeting completed'
,
verificationMethod
:
'MANUAL'
},
];
@
Injectable
()
export
class
ChecklistService
{
private
readonly
logger
=
new
Logger
(
ChecklistService
.
name
);
constructor
(
private
readonly
prisma
:
PrismaService
)
{}
async
getChecklist
(
userId
:
string
):
Promise
<
{
items
:
ChecklistItem
[];
completionPercentage
:
number
}
>
{
const
user
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
id
:
userId
,
deletedAt
:
null
},
});
if
(
!
user
)
{
throw
new
NotFoundException
(
'User not found'
);
}
const
storedChecklist
=
(
user
as
any
).
onboardingChecklist
as
Record
<
string
,
any
>
|
null
;
const
items
:
ChecklistItem
[]
=
DEFAULT_CHECKLIST_ITEMS
.
map
((
item
)
=>
{
const
stored
=
storedChecklist
?.[
item
.
key
];
return
{
...
item
,
completed
:
stored
?.
completed
||
false
,
completedAt
:
stored
?.
completedAt
||
null
,
verifiedBy
:
stored
?.
verifiedBy
||
null
,
};
});
// Auto-check automatic items
await
this
.
autoCheckItems
(
userId
,
items
);
const
completedCount
=
items
.
filter
((
i
)
=>
i
.
completed
).
length
;
const
completionPercentage
=
Math
.
round
((
completedCount
/
items
.
length
)
*
100
);
return
{
items
,
completionPercentage
};
}
async
updateItem
(
userId
:
string
,
itemKey
:
string
,
completed
:
boolean
,
verifiedById
:
string
):
Promise
<
void
>
{
const
user
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
id
:
userId
,
deletedAt
:
null
}
});
if
(
!
user
)
{
throw
new
NotFoundException
(
'User not found'
);
}
const
item
=
DEFAULT_CHECKLIST_ITEMS
.
find
((
i
)
=>
i
.
key
===
itemKey
);
if
(
!
item
)
{
throw
new
NotFoundException
(
'Checklist item not found'
);
}
const
checklist
=
((
user
as
any
).
onboardingChecklist
as
Record
<
string
,
any
>
)
||
{};
checklist
[
itemKey
]
=
{
completed
,
completedAt
:
completed
?
new
Date
().
toISOString
()
:
null
,
verifiedBy
:
completed
?
verifiedById
:
null
,
};
await
this
.
prisma
.
user
.
update
({
where
:
{
id
:
userId
},
data
:
{
onboardingChecklist
:
checklist
}
as
any
,
});
}
async
isComplete
(
userId
:
string
):
Promise
<
boolean
>
{
const
{
completionPercentage
}
=
await
this
.
getChecklist
(
userId
);
return
completionPercentage
===
100
;
}
private
async
autoCheckItems
(
userId
:
string
,
items
:
ChecklistItem
[]):
Promise
<
void
>
{
const
user
=
await
this
.
prisma
.
user
.
findUnique
({
where
:
{
id
:
userId
},
select
:
{
avatar
:
true
,
bankName
:
true
,
bankAccountNumber
:
true
,
onboardingChecklist
:
true
,
},
});
if
(
!
user
)
return
;
let
updated
=
false
;
const
checklist
=
((
user
as
any
).
onboardingChecklist
as
Record
<
string
,
any
>
)
||
{};
// Profile photo
const
photoItem
=
items
.
find
((
i
)
=>
i
.
key
===
'profile_photo'
);
if
(
photoItem
&&
!
photoItem
.
completed
&&
user
.
avatar
)
{
photoItem
.
completed
=
true
;
photoItem
.
completedAt
=
new
Date
().
toISOString
();
photoItem
.
verifiedBy
=
'SYSTEM'
;
checklist
[
'profile_photo'
]
=
{
completed
:
true
,
completedAt
:
photoItem
.
completedAt
,
verifiedBy
:
'SYSTEM'
};
updated
=
true
;
}
// Bank details
const
bankItem
=
items
.
find
((
i
)
=>
i
.
key
===
'bank_details'
);
if
(
bankItem
&&
!
bankItem
.
completed
&&
user
.
bankName
&&
user
.
bankAccountNumber
)
{
bankItem
.
completed
=
true
;
bankItem
.
completedAt
=
new
Date
().
toISOString
();
bankItem
.
verifiedBy
=
'SYSTEM'
;
checklist
[
'bank_details'
]
=
{
completed
:
true
,
completedAt
:
bankItem
.
completedAt
,
verifiedBy
:
'SYSTEM'
};
updated
=
true
;
}
if
(
updated
)
{
await
this
.
prisma
.
user
.
update
({
where
:
{
id
:
userId
},
data
:
{
onboardingChecklist
:
checklist
}
as
any
,
});
}
}
}
\ No newline at end of file
backend/src/modules/onboarding/dto/checklist-item.dto.ts
0 → 100644
View file @
f9cd0fe6
import
{
IsString
,
IsBoolean
}
from
'class-validator'
;
export
class
UpdateChecklistItemDto
{
@
IsString
()
itemKey
:
string
;
@
IsBoolean
()
completed
:
boolean
;
}
\ No newline at end of file
backend/src/modules/onboarding/dto/contract-sign.dto.ts
0 → 100644
View file @
f9cd0fe6
import
{
IsString
,
IsBoolean
,
IsArray
,
MinLength
}
from
'class-validator'
;
export
class
ContractSignDto
{
@
IsString
()
userId
:
string
;
@
IsArray
()
@
IsString
({
each
:
true
})
acknowledgedClauses
:
string
[];
// ['deduction_policy', 'ip_assignment', 'nda', 'termination_terms', 'code_of_conduct', 'data_security', 'salary_adjustment']
@
IsString
()
@
MinLength
(
4
)
signedFullName
:
string
;
@
IsBoolean
()
confirmedDigitalSignature
:
boolean
;
@
IsString
()
ipAddress
:
string
;
@
IsString
()
userAgent
:
string
;
}
\ No newline at end of file
backend/src/modules/onboarding/dto/create-invite.dto.ts
0 → 100644
View file @
f9cd0fe6
import
{
IsString
,
IsOptional
,
IsEnum
,
IsInt
,
Min
,
Max
,
IsArray
}
from
'class-validator'
;
export
class
CreateInviteDto
{
@
IsEnum
([
'FULL_TIME'
,
'PART_TIME'
,
'PROJECT_BASED'
])
contractorType
:
string
;
@
IsOptional
()
@
IsString
()
assignedProjectLeaderId
?:
string
;
@
IsOptional
()
@
IsArray
()
@
IsString
({
each
:
true
})
assignedBoardIds
?:
string
[];
@
IsOptional
()
@
IsInt
()
@
Min
(
1
)
@
Max
(
30
)
expirationDays
?:
number
;
@
IsOptional
()
@
IsString
()
welcomeNote
?:
string
;
}
\ No newline at end of file
backend/src/modules/onboarding/dto/register.dto.ts
0 → 100644
View file @
f9cd0fe6
import
{
IsString
,
MinLength
,
MaxLength
,
Matches
,
IsOptional
,
IsDateString
,
IsEnum
}
from
'class-validator'
;
export
class
RegisterDto
{
@
IsString
()
inviteCode
:
string
;
@
IsString
()
@
MinLength
(
4
)
nameArabic
:
string
;
@
IsString
()
@
MinLength
(
4
)
firstName
:
string
;
@
IsString
()
@
MinLength
(
4
)
lastName
:
string
;
@
IsString
()
@
Matches
(
/^
\d{14}
$/
,
{
message
:
'National ID must be 14 digits'
})
nationalId
:
string
;
@
IsDateString
()
dateOfBirth
:
string
;
@
IsString
()
@
Matches
(
/^01
\d{9}
$/
,
{
message
:
'Primary phone must be Egyptian format (01XXXXXXXXX)'
})
phone
:
string
;
@
IsOptional
()
@
IsString
()
@
Matches
(
/^01
\d{9}
$/
,
{
message
:
'Secondary phone must be Egyptian format (01XXXXXXXXX)'
})
phoneSecondary
?:
string
;
@
IsString
()
@
MinLength
(
20
,
{
message
:
'Address must be at least 20 characters'
})
address
:
string
;
@
IsString
()
@
MinLength
(
4
)
emergencyContactName
:
string
;
@
IsString
()
@
Matches
(
/^01
\d{9}
$/
)
emergencyContactPhone
:
string
;
@
IsEnum
([
'PARENT'
,
'SIBLING'
,
'SPOUSE'
,
'FRIEND'
,
'OTHER'
])
emergencyContactRelationship
:
string
;
@
IsString
()
bankName
:
string
;
@
IsString
()
bankAccountNumber
:
string
;
@
IsString
()
@
MinLength
(
4
)
bankAccountHolderName
:
string
;
@
IsOptional
()
@
IsString
()
taxRegistrationNumber
?:
string
;
@
IsString
()
@
MinLength
(
3
)
@
MaxLength
(
30
)
@
Matches
(
/^
[
a-zA-Z0-9_
]
+$/
,
{
message
:
'Username must be alphanumeric with underscores'
})
username
:
string
;
@
IsString
()
@
MinLength
(
10
)
@
Matches
(
/
(?=
.*
[
a-z
])
/
,
{
message
:
'Password must contain at least one lowercase letter'
})
@
Matches
(
/
(?=
.*
[
A-Z
])
/
,
{
message
:
'Password must contain at least one uppercase letter'
})
@
Matches
(
/
(?=
.*
\d)
/
,
{
message
:
'Password must contain at least one number'
})
@
Matches
(
/
(?=
.*
[
!@#$%^&*()_+
\-
=
\[\]
{};':"
\\
|,.<>
\/
?
])
/
,
{
message
:
'Password must contain at least one special character'
,
})
password
:
string
;
@
IsString
()
confirmPassword
:
string
;
}
\ No newline at end of file
backend/src/modules/onboarding/dto/schedule-config.dto.ts
0 → 100644
View file @
f9cd0fe6
import
{
IsObject
,
IsString
}
from
'class-validator'
;
export
class
ScheduleConfigDto
{
@
IsString
()
userId
:
string
;
@
IsObject
()
schedule
:
Record
<
string
,
string
>
;
// e.g. { sunday: 'IN_OFFICE', monday: 'IN_OFFICE', tuesday: 'REMOTE', wednesday: 'OFF', thursday: 'OFF' }
}
\ No newline at end of file
backend/src/modules/onboarding/dto/self-assessment.dto.ts
0 → 100644
View file @
f9cd0fe6
import
{
IsString
,
IsArray
,
ValidateNested
,
IsInt
,
Min
,
Max
}
from
'class-validator'
;
import
{
Type
}
from
'class-transformer'
;
export
class
CompetencyRatingDto
{
@
IsString
()
competencyAreaId
:
string
;
@
IsInt
()
@
Min
(
0
)
@
Max
(
5
)
level
:
number
;
}
export
class
SelfAssessmentDto
{
@
IsString
()
userId
:
string
;
@
IsArray
()
@
ValidateNested
({
each
:
true
})
@
Type
(()
=>
CompetencyRatingDto
)
ratings
:
CompetencyRatingDto
[];
}
\ No newline at end of file
backend/src/modules/onboarding/invite.service.ts
0 → 100644
View file @
f9cd0fe6
import
{
Injectable
,
NotFoundException
,
BadRequestException
,
ConflictException
,
Logger
,
}
from
'@nestjs/common'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
CreateInviteDto
}
from
'./dto/create-invite.dto'
;
import
{
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
import
*
as
crypto
from
'crypto'
;
@
Injectable
()
export
class
InviteService
{
private
readonly
logger
=
new
Logger
(
InviteService
.
name
);
constructor
(
private
readonly
prisma
:
PrismaService
)
{}
async
create
(
dto
:
CreateInviteDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
const
expirationDays
=
dto
.
expirationDays
||
7
;
const
code
=
this
.
generateInviteCode
();
const
token
=
crypto
.
randomUUID
();
const
expiresAt
=
new
Date
(
Date
.
now
()
+
expirationDays
*
24
*
60
*
60
*
1000
);
const
invite
=
await
this
.
prisma
.
invite
.
create
({
data
:
{
code
,
token
,
contractorType
:
dto
.
contractorType
,
assignedProjectLeaderId
:
dto
.
assignedProjectLeaderId
||
null
,
assignedBoardIds
:
dto
.
assignedBoardIds
||
[],
welcomeNote
:
dto
.
welcomeNote
||
null
,
expiresAt
,
status
:
'ACTIVE'
,
createdById
:
currentUser
.
id
,
},
});
this
.
logger
.
log
(
`Invite
${
code
}
created by
${
currentUser
.
email
}
, expires
${
expiresAt
.
toISOString
()}
`
);
return
{
id
:
invite
.
id
,
code
:
invite
.
code
,
token
:
invite
.
token
,
contractorType
:
invite
.
contractorType
,
expiresAt
:
invite
.
expiresAt
,
status
:
invite
.
status
,
createdAt
:
invite
.
createdAt
,
};
}
async
findAll
(
currentUser
:
RequestUser
):
Promise
<
any
[]
>
{
const
invites
=
await
this
.
prisma
.
invite
.
findMany
({
orderBy
:
{
createdAt
:
'desc'
},
include
:
{
createdBy
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
username
:
true
}
},
usedBy
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
username
:
true
}
},
},
});
// Auto-expire stale invites
const
now
=
new
Date
();
for
(
const
invite
of
invites
)
{
if
(
invite
.
status
===
'ACTIVE'
&&
invite
.
expiresAt
<
now
)
{
await
this
.
prisma
.
invite
.
update
({
where
:
{
id
:
invite
.
id
},
data
:
{
status
:
'EXPIRED'
},
});
invite
.
status
=
'EXPIRED'
;
}
}
return
invites
;
}
async
findByCode
(
code
:
string
):
Promise
<
any
>
{
const
invite
=
await
this
.
prisma
.
invite
.
findFirst
({
where
:
{
OR
:
[{
code
},
{
token
:
code
}]
},
});
if
(
!
invite
)
{
throw
new
NotFoundException
(
'Invalid invite code'
);
}
if
(
invite
.
status
!==
'ACTIVE'
)
{
throw
new
BadRequestException
(
`This invitation has been
${
invite
.
status
.
toLowerCase
()}
. Contact your administrator.`
);
}
if
(
invite
.
expiresAt
<
new
Date
())
{
await
this
.
prisma
.
invite
.
update
({
where
:
{
id
:
invite
.
id
},
data
:
{
status
:
'EXPIRED'
}
});
throw
new
BadRequestException
(
'This invitation has expired. Contact your administrator.'
);
}
return
invite
;
}
async
revoke
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
void
>
{
const
invite
=
await
this
.
prisma
.
invite
.
findUnique
({
where
:
{
id
}
});
if
(
!
invite
)
{
throw
new
NotFoundException
(
'Invite not found'
);
}
if
(
invite
.
status
!==
'ACTIVE'
)
{
throw
new
BadRequestException
(
'Only active invites can be revoked'
);
}
await
this
.
prisma
.
invite
.
update
({
where
:
{
id
},
data
:
{
status
:
'REVOKED'
},
});
this
.
logger
.
log
(
`Invite
${
invite
.
code
}
revoked by
${
currentUser
.
email
}
`
);
}
async
markAsUsed
(
inviteId
:
string
,
userId
:
string
):
Promise
<
void
>
{
await
this
.
prisma
.
invite
.
update
({
where
:
{
id
:
inviteId
},
data
:
{
status
:
'USED'
,
usedById
:
userId
,
usedAt
:
new
Date
(),
},
});
}
async
delete
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
void
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
BadRequestException
(
'Only Super Admin can delete invite records'
);
}
await
this
.
prisma
.
invite
.
delete
({
where
:
{
id
}
});
this
.
logger
.
log
(
`Invite
${
id
}
deleted by
${
currentUser
.
email
}
`
);
}
private
generateInviteCode
():
string
{
const
chars
=
'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
;
let
code
=
''
;
const
bytes
=
crypto
.
randomBytes
(
8
);
for
(
let
i
=
0
;
i
<
8
;
i
++
)
{
code
+=
chars
[
bytes
[
i
]
%
chars
.
length
];
}
return
code
;
}
}
\ No newline at end of file
backend/src/modules/onboarding/onboarding.controller.ts
0 → 100644
View file @
f9cd0fe6
import
{
Controller
,
Get
,
Post
,
Put
,
Delete
,
Body
,
Param
,
Query
,
HttpCode
,
HttpStatus
,
}
from
'@nestjs/common'
;
import
{
OnboardingService
}
from
'./onboarding.service'
;
import
{
InviteService
}
from
'./invite.service'
;
import
{
ChecklistService
}
from
'./checklist.service'
;
import
{
SalaryCalculatorService
}
from
'./salary-calculator.service'
;
import
{
CreateInviteDto
}
from
'./dto/create-invite.dto'
;
import
{
RegisterDto
}
from
'./dto/register.dto'
;
import
{
ScheduleConfigDto
}
from
'./dto/schedule-config.dto'
;
import
{
ContractSignDto
}
from
'./dto/contract-sign.dto'
;
import
{
SelfAssessmentDto
}
from
'./dto/self-assessment.dto'
;
import
{
UpdateChecklistItemDto
}
from
'./dto/checklist-item.dto'
;
import
{
Public
}
from
'../../common/decorators/public.decorator'
;
import
{
Roles
}
from
'../../common/decorators/roles.decorator'
;
import
{
CurrentUser
,
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
@
Controller
(
'onboarding'
)
export
class
OnboardingController
{
constructor
(
private
readonly
onboardingService
:
OnboardingService
,
private
readonly
inviteService
:
InviteService
,
private
readonly
checklistService
:
ChecklistService
,
private
readonly
salaryCalculator
:
SalaryCalculatorService
,
)
{}
// ─── INVITES ─────────────────────────────────────────
@
Post
(
'invites'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
createInvite
(@
Body
()
dto
:
CreateInviteDto
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
inviteService
.
create
(
dto
,
user
);
}
@
Get
(
'invites'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
listInvites
(@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
inviteService
.
findAll
(
user
);
}
@
Delete
(
'invites/:id'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
revokeInvite
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
await
this
.
inviteService
.
revoke
(
id
,
user
);
return
{
message
:
'Invite revoked'
};
}
@
Delete
(
'invites/:id/permanent'
)
@
Roles
(
'SUPER_ADMIN'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
deleteInvite
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
await
this
.
inviteService
.
delete
(
id
,
user
);
return
{
message
:
'Invite deleted permanently'
};
}
// ─── PUBLIC REGISTRATION FLOW ────────────────────────
@
Public
()
@
Post
(
'validate-invite'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
validateInvite
(@
Body
(
'code'
)
code
:
string
)
{
return
this
.
onboardingService
.
validateInvite
(
code
);
}
@
Public
()
@
Get
(
'check-unique'
)
async
checkUnique
(@
Query
(
'field'
)
field
:
string
,
@
Query
(
'value'
)
value
:
string
)
{
return
this
.
onboardingService
.
checkUniqueField
(
field
,
value
);
}
@
Public
()
@
Post
(
'register'
)
async
register
(@
Body
()
dto
:
RegisterDto
)
{
return
this
.
onboardingService
.
register
(
dto
);
}
// ─── ONBOARDING STEPS (AUTHENTICATED) ────────────────
@
Post
(
'schedule'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
configureSchedule
(@
Body
()
dto
:
ScheduleConfigDto
)
{
return
this
.
onboardingService
.
configureSchedule
(
dto
);
}
@
Post
(
'schedule/preview'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
previewSalary
(@
Body
()
dto
:
ScheduleConfigDto
)
{
const
user
=
await
this
.
salaryCalculator
.
calculateBaseSalary
(
dto
.
schedule
,
'FULL_TIME'
,
// will be overridden by actual user type in full flow
);
return
user
;
}
@
Post
(
'contract'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
signContract
(@
Body
()
dto
:
ContractSignDto
)
{
return
this
.
onboardingService
.
signContract
(
dto
);
}
@
Post
(
'self-assessment'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
submitSelfAssessment
(@
Body
()
dto
:
SelfAssessmentDto
)
{
return
this
.
onboardingService
.
submitSelfAssessment
(
dto
);
}
// ─── CHECKLIST ───────────────────────────────────────
@
Get
(
'checklist/:userId'
)
async
getChecklist
(@
Param
(
'userId'
)
userId
:
string
)
{
return
this
.
checklistService
.
getChecklist
(
userId
);
}
@
Put
(
'checklist/:userId/items'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
updateChecklistItem
(
@
Param
(
'userId'
)
userId
:
string
,
@
Body
()
dto
:
UpdateChecklistItemDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
await
this
.
checklistService
.
updateItem
(
userId
,
dto
.
itemKey
,
dto
.
completed
,
user
.
id
);
return
{
message
:
'Checklist item updated'
};
}
// ─── ACTIVATION ──────────────────────────────────────
@
Post
(
'activate/:userId'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
activate
(@
Param
(
'userId'
)
userId
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
onboardingService
.
activate
(
userId
,
user
);
}
}
\ No newline at end of file
backend/src/modules/onboarding/onboarding.module.ts
0 → 100644
View file @
f9cd0fe6
import
{
Module
}
from
'@nestjs/common'
;
import
{
OnboardingController
}
from
'./onboarding.controller'
;
import
{
OnboardingService
}
from
'./onboarding.service'
;
import
{
InviteService
}
from
'./invite.service'
;
import
{
ChecklistService
}
from
'./checklist.service'
;
import
{
SalaryCalculatorService
}
from
'./salary-calculator.service'
;
@
Module
({
controllers
:
[
OnboardingController
],
providers
:
[
OnboardingService
,
InviteService
,
ChecklistService
,
SalaryCalculatorService
],
exports
:
[
OnboardingService
,
InviteService
,
ChecklistService
,
SalaryCalculatorService
],
})
export
class
OnboardingModule
{}
\ No newline at end of file
backend/src/modules/onboarding/onboarding.service.ts
0 → 100644
View file @
f9cd0fe6
import
{
Injectable
,
BadRequestException
,
ConflictException
,
ForbiddenException
,
NotFoundException
,
Logger
,
}
from
'@nestjs/common'
;
import
*
as
bcrypt
from
'bcrypt'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
InviteService
}
from
'./invite.service'
;
import
{
SalaryCalculatorService
}
from
'./salary-calculator.service'
;
import
{
ChecklistService
}
from
'./checklist.service'
;
import
{
RegisterDto
}
from
'./dto/register.dto'
;
import
{
ScheduleConfigDto
}
from
'./dto/schedule-config.dto'
;
import
{
ContractSignDto
}
from
'./dto/contract-sign.dto'
;
import
{
SelfAssessmentDto
}
from
'./dto/self-assessment.dto'
;
import
{
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
@
Injectable
()
export
class
OnboardingService
{
private
readonly
logger
=
new
Logger
(
OnboardingService
.
name
);
private
readonly
BCRYPT_ROUNDS
=
12
;
constructor
(
private
readonly
prisma
:
PrismaService
,
private
readonly
inviteService
:
InviteService
,
private
readonly
salaryCalculator
:
SalaryCalculatorService
,
private
readonly
checklistService
:
ChecklistService
,
)
{}
async
validateInvite
(
code
:
string
):
Promise
<
any
>
{
const
invite
=
await
this
.
inviteService
.
findByCode
(
code
);
return
{
valid
:
true
,
contractorType
:
invite
.
contractorType
,
welcomeNote
:
invite
.
welcomeNote
,
expiresAt
:
invite
.
expiresAt
,
};
}
async
register
(
dto
:
RegisterDto
):
Promise
<
any
>
{
if
(
dto
.
password
!==
dto
.
confirmPassword
)
{
throw
new
BadRequestException
(
'Passwords do not match'
);
}
// Validate invite
const
invite
=
await
this
.
inviteService
.
findByCode
(
dto
.
inviteCode
);
// Validate age (at least 16)
const
dob
=
new
Date
(
dto
.
dateOfBirth
);
const
age
=
Math
.
floor
((
Date
.
now
()
-
dob
.
getTime
())
/
(
365.25
*
24
*
60
*
60
*
1000
));
if
(
age
<
16
)
{
throw
new
BadRequestException
(
'Must be at least 16 years old'
);
}
// Uniqueness checks
const
existingUsername
=
await
this
.
prisma
.
user
.
findUnique
({
where
:
{
username
:
dto
.
username
}
});
if
(
existingUsername
)
throw
new
ConflictException
(
'Username already in use'
);
const
existingNationalId
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
nationalId
:
dto
.
nationalId
}
});
if
(
existingNationalId
)
throw
new
ConflictException
(
'National ID already in use'
);
const
existingPhone
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
phone
:
dto
.
phone
}
});
if
(
existingPhone
)
throw
new
ConflictException
(
'Phone number already in use'
);
const
passwordHash
=
await
bcrypt
.
hash
(
dto
.
password
,
this
.
BCRYPT_ROUNDS
);
const
user
=
await
this
.
prisma
.
user
.
create
({
data
:
{
email
:
`
${
dto
.
username
}
@thegrind.local`
,
// Internal email, no external email system
username
:
dto
.
username
,
firstName
:
dto
.
firstName
,
lastName
:
dto
.
lastName
,
nameArabic
:
dto
.
nameArabic
,
nationalId
:
dto
.
nationalId
,
dateOfBirth
:
dob
,
phone
:
dto
.
phone
,
phoneSecondary
:
dto
.
phoneSecondary
||
null
,
address
:
dto
.
address
,
emergencyContactName
:
dto
.
emergencyContactName
,
emergencyContactPhone
:
dto
.
emergencyContactPhone
,
emergencyContactRelationship
:
dto
.
emergencyContactRelationship
,
bankName
:
dto
.
bankName
,
bankAccountNumber
:
dto
.
bankAccountNumber
,
bankAccountHolderName
:
dto
.
bankAccountHolderName
,
taxRegistrationNumber
:
dto
.
taxRegistrationNumber
||
null
,
passwordHash
,
role
:
'CONTRACTOR'
,
status
:
'ONBOARDING'
,
contractorType
:
invite
.
contractorType
,
assignedProjectLeaderId
:
invite
.
assignedProjectLeaderId
||
null
,
timezone
:
'Africa/Cairo'
,
forcePasswordChange
:
false
,
onboardingChecklist
:
{},
},
});
// Mark invite as used
await
this
.
inviteService
.
markAsUsed
(
invite
.
id
,
user
.
id
);
// Auto-assign boards if specified
if
(
invite
.
assignedBoardIds
&&
invite
.
assignedBoardIds
.
length
>
0
)
{
for
(
const
boardId
of
invite
.
assignedBoardIds
)
{
try
{
await
this
.
prisma
.
boardMember
.
create
({
data
:
{
boardId
,
userId
:
user
.
id
,
role
:
'MEMBER'
,
},
});
}
catch
(
err
)
{
this
.
logger
.
warn
(
`Failed to assign board
${
boardId
}
to user
${
user
.
id
}
:
${
err
.
message
}
`
);
}
}
}
this
.
logger
.
log
(
`User
${
user
.
username
}
registered via invite
${
invite
.
code
}
`
);
return
{
userId
:
user
.
id
,
username
:
user
.
username
,
contractorType
:
user
.
contractorType
,
status
:
user
.
status
,
message
:
'Registration successful. Proceed to schedule configuration.'
,
};
}
async
configureSchedule
(
dto
:
ScheduleConfigDto
):
Promise
<
any
>
{
const
user
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
id
:
dto
.
userId
,
deletedAt
:
null
},
});
if
(
!
user
)
throw
new
NotFoundException
(
'User not found'
);
if
(
user
.
status
!==
'ONBOARDING'
)
throw
new
BadRequestException
(
'User is not in onboarding status'
);
// Validate schedule — at least 1 working day
const
workingDays
=
Object
.
values
(
dto
.
schedule
).
filter
((
v
)
=>
v
!==
'OFF'
);
if
(
workingDays
.
length
===
0
)
{
throw
new
BadRequestException
(
'Schedule must have at least 1 working day'
);
}
// Calculate base salary
const
{
baseSalaryPiasters
,
breakdown
}
=
await
this
.
salaryCalculator
.
calculateBaseSalary
(
dto
.
schedule
,
user
.
contractorType
||
'FULL_TIME'
,
);
await
this
.
prisma
.
user
.
update
({
where
:
{
id
:
dto
.
userId
},
data
:
{
weeklySchedule
:
dto
.
schedule
,
baseSalaryPiasters
,
},
});
return
{
schedule
:
dto
.
schedule
,
baseSalaryPiasters
,
breakdown
,
message
:
'Schedule configured. Proceed to contract signing.'
,
};
}
async
signContract
(
dto
:
ContractSignDto
):
Promise
<
any
>
{
const
user
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
id
:
dto
.
userId
,
deletedAt
:
null
},
});
if
(
!
user
)
throw
new
NotFoundException
(
'User not found'
);
if
(
user
.
status
!==
'ONBOARDING'
)
throw
new
BadRequestException
(
'User is not in onboarding status'
);
// Validate all required clauses are acknowledged
const
requiredClauses
=
[
'deduction_policy'
,
'ip_assignment'
,
'nda'
,
'termination_terms'
,
'code_of_conduct'
,
'data_security'
,
'salary_adjustment'
,
];
for
(
const
clause
of
requiredClauses
)
{
if
(
!
dto
.
acknowledgedClauses
.
includes
(
clause
))
{
throw
new
BadRequestException
(
`Must acknowledge clause:
${
clause
}
`
);
}
}
// Validate digital signature matches
const
expectedName
=
`
${
user
.
firstName
}
${
user
.
lastName
}
`
;
if
(
dto
.
signedFullName
.
trim
().
toLowerCase
()
!==
expectedName
.
trim
().
toLowerCase
())
{
throw
new
BadRequestException
(
'Signed name must match your full legal name (English)'
);
}
if
(
!
dto
.
confirmedDigitalSignature
)
{
throw
new
BadRequestException
(
'Must confirm digital signature checkbox'
);
}
// Create contract record
try
{
await
this
.
prisma
.
contract
.
create
({
data
:
{
userId
:
dto
.
userId
,
contractType
:
user
.
contractorType
||
'FULL_TIME'
,
contractText
:
this
.
generateContractText
(
user
),
signedAt
:
new
Date
(),
signedFullName
:
dto
.
signedFullName
,
acknowledgedClauses
:
dto
.
acknowledgedClauses
,
signatureIpAddress
:
dto
.
ipAddress
,
signatureUserAgent
:
dto
.
userAgent
,
baseSalaryAtSigning
:
user
.
baseSalaryPiasters
,
scheduleAtSigning
:
user
.
weeklySchedule
as
any
,
startDate
:
new
Date
(),
status
:
'ACTIVE'
,
},
});
}
catch
(
err
)
{
this
.
logger
.
warn
(
`Contract table may not exist yet:
${
err
.
message
}
`
);
}
// Update checklist
await
this
.
checklistService
.
updateItem
(
dto
.
userId
,
'contract_signed'
,
true
,
'SYSTEM'
);
await
this
.
checklistService
.
updateItem
(
dto
.
userId
,
'policies_acknowledged'
,
true
,
'SYSTEM'
);
return
{
message
:
'Contract signed successfully. Proceed to competency self-assessment.'
};
}
async
submitSelfAssessment
(
dto
:
SelfAssessmentDto
):
Promise
<
any
>
{
const
user
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
id
:
dto
.
userId
,
deletedAt
:
null
},
});
if
(
!
user
)
throw
new
NotFoundException
(
'User not found'
);
if
(
user
.
status
!==
'ONBOARDING'
)
throw
new
BadRequestException
(
'User is not in onboarding status'
);
// Validate all competency areas are rated
const
allAreas
=
await
this
.
prisma
.
competencyArea
.
findMany
({
where
:
{
isActive
:
true
},
orderBy
:
{
order
:
'asc'
},
});
if
(
dto
.
ratings
.
length
<
allAreas
.
length
)
{
throw
new
BadRequestException
(
`Must rate all
${
allAreas
.
length
}
competency areas`
);
}
// Store self-assessment ratings
for
(
const
rating
of
dto
.
ratings
)
{
try
{
await
this
.
prisma
.
competencyRating
.
upsert
({
where
:
{
userId_competencyAreaId_type
:
{
userId
:
dto
.
userId
,
competencyAreaId
:
rating
.
competencyAreaId
,
type
:
'SELF'
,
},
},
update
:
{
level
:
rating
.
level
},
create
:
{
userId
:
dto
.
userId
,
competencyAreaId
:
rating
.
competencyAreaId
,
type
:
'SELF'
,
level
:
rating
.
level
,
},
});
}
catch
(
err
)
{
this
.
logger
.
warn
(
`CompetencyRating upsert failed:
${
err
.
message
}
. Table may not exist.`
);
}
}
// Update checklist
await
this
.
checklistService
.
updateItem
(
dto
.
userId
,
'self_assessment'
,
true
,
'SYSTEM'
);
// Identify learning gaps (level 0 or 1)
const
gaps
=
dto
.
ratings
.
filter
((
r
)
=>
r
.
level
<=
1
);
return
{
message
:
'Self-assessment submitted.'
,
gapsIdentified
:
gaps
.
length
,
learningGoalsWillBeCreated
:
gaps
.
length
,
};
}
async
activate
(
userId
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can activate contractors'
);
}
const
user
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
id
:
userId
,
deletedAt
:
null
},
});
if
(
!
user
)
throw
new
NotFoundException
(
'User not found'
);
if
(
user
.
status
!==
'ONBOARDING'
)
throw
new
BadRequestException
(
'User is not in onboarding status'
);
// Check checklist completion
const
isComplete
=
await
this
.
checklistService
.
isComplete
(
userId
);
if
(
!
isComplete
)
{
throw
new
BadRequestException
(
'Onboarding checklist is not 100% complete'
);
}
// Activate
const
activated
=
await
this
.
prisma
.
user
.
update
({
where
:
{
id
:
userId
},
data
:
{
status
:
'ACTIVE'
,
startDate
:
new
Date
(),
// If no actual salary set yet, use base salary
actualSalaryPiasters
:
user
.
actualSalaryPiasters
||
user
.
baseSalaryPiasters
,
},
});
this
.
logger
.
log
(
`User
${
user
.
username
}
activated by
${
currentUser
.
email
}
`
);
// TODO: When notifications module is built:
// - Send blocking notification to SA: "Set Actual Salary"
// - Send welcome notification to contractor
// - Create learning goals from self-assessment gaps
return
{
id
:
activated
.
id
,
username
:
activated
.
username
,
status
:
activated
.
status
,
actualSalaryPiasters
:
activated
.
actualSalaryPiasters
,
message
:
`Contractor
${
activated
.
firstName
}
${
activated
.
lastName
}
is now active.`
,
};
}
async
checkUniqueField
(
field
:
string
,
value
:
string
):
Promise
<
{
available
:
boolean
}
>
{
let
existing
:
any
=
null
;
switch
(
field
)
{
case
'username'
:
existing
=
await
this
.
prisma
.
user
.
findUnique
({
where
:
{
username
:
value
}
});
break
;
case
'nationalId'
:
existing
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
nationalId
:
value
}
});
break
;
case
'phone'
:
existing
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
phone
:
value
}
});
break
;
default
:
throw
new
BadRequestException
(
`Cannot check uniqueness of field:
${
field
}
`
);
}
return
{
available
:
!
existing
};
}
private
generateContractText
(
user
:
any
):
string
{
// This generates the frozen contract snapshot
return
`
COMPREHENSIVE SERVICE AGREEMENT
This agreement is entered into between AL-Arcade ("The Company") and
${
user
.
firstName
}
${
user
.
lastName
}
("The Contractor").
Contractor Type:
${
user
.
contractorType
}
Registration Date:
${
new
Date
().
toISOString
().
split
(
'T'
)[
0
]}
TERMS AND CONDITIONS:
1. DEDUCTION POLICY
The Contractor acknowledges the Company's deduction system as outlined in the operational handbook.
2. INTELLECTUAL PROPERTY ASSIGNMENT
All work produced during the engagement is the intellectual property of AL-Arcade.
3. NON-DISCLOSURE AGREEMENT
The Contractor agrees not to disclose any confidential information.
4. TERMINATION TERMS
Either party may terminate with appropriate notice as defined in the handbook.
5. CODE OF CONDUCT
The Contractor agrees to abide by the Company's code of conduct.
6. DATA & SECURITY POLICY
The Contractor agrees to follow all data handling and security policies.
7. SALARY ADJUSTMENT
The Contractor acknowledges that the Super Admin may adjust salary at any time.
This document constitutes a legally binding digital agreement.
`
.
trim
();
}
}
\ No newline at end of file
backend/src/modules/onboarding/salary-calculator.service.ts
0 → 100644
View file @
f9cd0fe6
import
{
Injectable
,
Logger
}
from
'@nestjs/common'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
calculateBaseSalaryPiasters
}
from
'../../common/utils/salary.util'
;
@
Injectable
()
export
class
SalaryCalculatorService
{
private
readonly
logger
=
new
Logger
(
SalaryCalculatorService
.
name
);
constructor
(
private
readonly
prisma
:
PrismaService
)
{}
async
calculateBaseSalary
(
schedule
:
Record
<
string
,
string
>
,
contractorType
:
string
,
):
Promise
<
{
baseSalaryPiasters
:
number
;
breakdown
:
any
}
>
{
const
rates
=
await
this
.
getSalaryRates
();
const
baseSalaryPiasters
=
calculateBaseSalaryPiasters
(
schedule
,
contractorType
,
rates
);
const
isFullTime
=
contractorType
===
'FULL_TIME'
;
const
inOfficeRate
=
isFullTime
?
rates
.
fullTimeInOffice
:
rates
.
internInOffice
;
const
remoteRate
=
isFullTime
?
rates
.
fullTimeRemote
:
rates
.
internRemote
;
const
breakdown
:
any
[]
=
[];
for
(
const
[
day
,
type
]
of
Object
.
entries
(
schedule
))
{
if
(
type
===
'IN_OFFICE'
)
{
breakdown
.
push
({
day
,
type
,
ratePiasters
:
inOfficeRate
});
}
else
if
(
type
===
'REMOTE'
)
{
breakdown
.
push
({
day
,
type
,
ratePiasters
:
remoteRate
});
}
}
return
{
baseSalaryPiasters
,
breakdown
};
}
async
recalculateForUser
(
userId
:
string
):
Promise
<
number
>
{
const
user
=
await
this
.
prisma
.
user
.
findUnique
({
where
:
{
id
:
userId
},
select
:
{
weeklySchedule
:
true
,
contractorType
:
true
},
});
if
(
!
user
||
!
user
.
weeklySchedule
||
!
user
.
contractorType
)
{
return
0
;
}
const
{
baseSalaryPiasters
}
=
await
this
.
calculateBaseSalary
(
user
.
weeklySchedule
as
Record
<
string
,
string
>
,
user
.
contractorType
,
);
await
this
.
prisma
.
user
.
update
({
where
:
{
id
:
userId
},
data
:
{
baseSalaryPiasters
},
});
return
baseSalaryPiasters
;
}
private
async
getSalaryRates
():
Promise
<
{
fullTimeInOffice
:
number
;
fullTimeRemote
:
number
;
internInOffice
:
number
;
internRemote
:
number
;
}
>
{
const
settings
=
await
this
.
prisma
.
setting
.
findMany
({
where
:
{
key
:
{
in
:
[
'fullTimeInOfficeRate'
,
'fullTimeRemoteRate'
,
'internInOfficeRate'
,
'internRemoteRate'
],
},
},
});
const
map
:
Record
<
string
,
any
>
=
{};
for
(
const
s
of
settings
)
{
map
[
s
.
key
]
=
s
.
value
;
}
return
{
fullTimeInOffice
:
(
map
[
'fullTimeInOfficeRate'
]
as
number
)
||
240000
,
fullTimeRemote
:
(
map
[
'fullTimeRemoteRate'
]
as
number
)
||
160000
,
internInOffice
:
(
map
[
'internInOfficeRate'
]
as
number
)
||
100000
,
internRemote
:
(
map
[
'internRemoteRate'
]
as
number
)
||
50000
,
};
}
}
\ No newline at end of file
backend/src/modules/users/dto/create-user.dto.ts
0 → 100644
View file @
f9cd0fe6
import
{
IsString
,
IsEmail
,
IsOptional
,
MinLength
,
MaxLength
,
Matches
,
IsEnum
,
IsDateString
,
}
from
'class-validator'
;
export
class
CreateUserDto
{
@
IsEmail
()
email
:
string
;
@
IsString
()
@
MinLength
(
3
)
@
MaxLength
(
30
)
@
Matches
(
/^
[
a-zA-Z0-9_
]
+$/
,
{
message
:
'Username must be alphanumeric with underscores only'
})
username
:
string
;
@
IsString
()
@
MinLength
(
1
)
firstName
:
string
;
@
IsString
()
@
MinLength
(
1
)
lastName
:
string
;
@
IsOptional
()
@
IsString
()
displayName
?:
string
;
@
IsOptional
()
@
IsString
()
nameArabic
?:
string
;
@
IsOptional
()
@
IsString
()
nationalId
?:
string
;
@
IsOptional
()
@
IsDateString
()
dateOfBirth
?:
string
;
@
IsOptional
()
@
IsString
()
phone
?:
string
;
@
IsOptional
()
@
IsString
()
phoneSecondary
?:
string
;
@
IsOptional
()
@
IsString
()
address
?:
string
;
@
IsOptional
()
@
IsString
()
emergencyContactName
?:
string
;
@
IsOptional
()
@
IsString
()
emergencyContactPhone
?:
string
;
@
IsOptional
()
@
IsString
()
emergencyContactRelationship
?:
string
;
@
IsOptional
()
@
IsString
()
bankName
?:
string
;
@
IsOptional
()
@
IsString
()
bankAccountNumber
?:
string
;
@
IsOptional
()
@
IsString
()
bankAccountHolderName
?:
string
;
@
IsOptional
()
@
IsString
()
taxRegistrationNumber
?:
string
;
@
IsEnum
([
'SUPER_ADMIN'
,
'ADMIN'
,
'TEAM_LEAD'
,
'CONTRACTOR'
])
role
:
string
;
@
IsOptional
()
@
IsEnum
([
'FULL_TIME'
,
'PART_TIME'
,
'PROJECT_BASED'
])
contractorType
?:
string
;
@
IsOptional
()
@
IsString
()
department
?:
string
;
@
IsOptional
()
@
IsString
()
title
?:
string
;
@
IsOptional
()
@
IsString
()
bio
?:
string
;
@
IsOptional
()
@
IsString
()
timezone
?:
string
;
@
IsString
()
@
MinLength
(
10
,
{
message
:
'Password must be at least 10 characters'
})
@
Matches
(
/
(?=
.*
[
a-z
])
/
,
{
message
:
'Password must contain at least one lowercase letter'
})
@
Matches
(
/
(?=
.*
[
A-Z
])
/
,
{
message
:
'Password must contain at least one uppercase letter'
})
@
Matches
(
/
(?=
.*
\d)
/
,
{
message
:
'Password must contain at least one number'
})
@
Matches
(
/
(?=
.*
[
!@#$%^&*()_+
\-
=
\[\]
{};':"
\\
|,.<>
\/
?
])
/
,
{
message
:
'Password must contain at least one special character'
,
})
password
:
string
;
}
\ No newline at end of file
backend/src/modules/users/dto/reset-password.dto.ts
0 → 100644
View file @
f9cd0fe6
import
{
IsOptional
,
IsString
,
MinLength
}
from
'class-validator'
;
export
class
ResetPasswordResponseDto
{
temporaryPassword
:
string
;
message
:
string
;
}
export
class
SetActualSalaryDto
{
@
MinLength
(
0
)
actualSalaryPiasters
:
number
;
@
IsOptional
()
@
IsString
()
@
MinLength
(
1
)
reason
?:
string
;
}
export
class
AddPrivateNoteDto
{
@
IsString
()
@
MinLength
(
1
)
content
:
string
;
}
export
class
ChangeStatusDto
{
@
IsString
()
status
:
string
;
@
IsOptional
()
@
IsString
()
reason
?:
string
;
}
\ No newline at end of file
backend/src/modules/users/dto/update-user.dto.ts
0 → 100644
View file @
f9cd0fe6
import
{
IsString
,
IsOptional
,
IsEmail
,
MinLength
,
MaxLength
,
Matches
,
IsEnum
,
IsDateString
,
IsInt
,
Min
,
IsObject
,
}
from
'class-validator'
;
export
class
UpdateUserDto
{
@
IsOptional
()
@
IsEmail
()
email
?:
string
;
@
IsOptional
()
@
IsString
()
@
MinLength
(
3
)
@
MaxLength
(
30
)
@
Matches
(
/^
[
a-zA-Z0-9_
]
+$/
,
{
message
:
'Username must be alphanumeric with underscores only'
})
username
?:
string
;
@
IsOptional
()
@
IsString
()
firstName
?:
string
;
@
IsOptional
()
@
IsString
()
lastName
?:
string
;
@
IsOptional
()
@
IsString
()
displayName
?:
string
;
@
IsOptional
()
@
IsString
()
nameArabic
?:
string
;
@
IsOptional
()
@
IsString
()
nationalId
?:
string
;
@
IsOptional
()
@
IsDateString
()
dateOfBirth
?:
string
;
@
IsOptional
()
@
IsString
()
phone
?:
string
;
@
IsOptional
()
@
IsString
()
phoneSecondary
?:
string
;
@
IsOptional
()
@
IsString
()
address
?:
string
;
@
IsOptional
()
@
IsString
()
avatar
?:
string
;
@
IsOptional
()
@
IsString
()
emergencyContactName
?:
string
;
@
IsOptional
()
@
IsString
()
emergencyContactPhone
?:
string
;
@
IsOptional
()
@
IsString
()
emergencyContactRelationship
?:
string
;
@
IsOptional
()
@
IsString
()
bankName
?:
string
;
@
IsOptional
()
@
IsString
()
bankAccountNumber
?:
string
;
@
IsOptional
()
@
IsString
()
bankAccountHolderName
?:
string
;
@
IsOptional
()
@
IsString
()
taxRegistrationNumber
?:
string
;
@
IsOptional
()
@
IsEnum
([
'SUPER_ADMIN'
,
'ADMIN'
,
'TEAM_LEAD'
,
'CONTRACTOR'
])
role
?:
string
;
@
IsOptional
()
@
IsEnum
([
'INVITED'
,
'ONBOARDING'
,
'ACTIVE'
,
'ON_PIP'
,
'SUSPENDED'
,
'OFFBOARDING'
,
'OFFBOARDED'
])
status
?:
string
;
@
IsOptional
()
@
IsEnum
([
'FULL_TIME'
,
'PART_TIME'
,
'PROJECT_BASED'
])
contractorType
?:
string
;
@
IsOptional
()
@
IsString
()
department
?:
string
;
@
IsOptional
()
@
IsString
()
title
?:
string
;
@
IsOptional
()
@
IsString
()
bio
?:
string
;
@
IsOptional
()
@
IsString
()
timezone
?:
string
;
@
IsOptional
()
@
IsObject
()
weeklySchedule
?:
Record
<
string
,
string
>
;
@
IsOptional
()
@
IsInt
()
@
Min
(
0
)
actualSalaryPiasters
?:
number
;
@
IsOptional
()
@
IsString
()
salaryChangeReason
?:
string
;
@
IsOptional
()
@
IsDateString
()
startDate
?:
string
;
@
IsOptional
()
@
IsDateString
()
contractStartDate
?:
string
;
@
IsOptional
()
@
IsDateString
()
contractEndDate
?:
string
;
@
IsOptional
()
@
IsString
()
assignedProjectLeaderId
?:
string
;
}
\ No newline at end of file
backend/src/modules/users/dto/user-filter.dto.ts
0 → 100644
View file @
f9cd0fe6
import
{
IsOptional
,
IsString
,
IsEnum
}
from
'class-validator'
;
import
{
PaginationDto
}
from
'../../../common/dto/pagination.dto'
;
export
class
UserFilterDto
extends
PaginationDto
{
@
IsOptional
()
@
IsEnum
([
'SUPER_ADMIN'
,
'ADMIN'
,
'TEAM_LEAD'
,
'CONTRACTOR'
])
role
?:
string
;
@
IsOptional
()
@
IsEnum
([
'INVITED'
,
'ONBOARDING'
,
'ACTIVE'
,
'ON_PIP'
,
'SUSPENDED'
,
'OFFBOARDING'
,
'OFFBOARDED'
])
status
?:
string
;
@
IsOptional
()
@
IsEnum
([
'FULL_TIME'
,
'PART_TIME'
,
'PROJECT_BASED'
])
contractorType
?:
string
;
@
IsOptional
()
@
IsString
()
department
?:
string
;
@
IsOptional
()
@
IsString
()
boardId
?:
string
;
}
\ No newline at end of file
backend/src/modules/users/dto/user-response.dto.ts
0 → 100644
View file @
f9cd0fe6
export
class
UserResponseDto
{
id
:
string
;
email
:
string
;
username
:
string
;
firstName
:
string
;
lastName
:
string
;
displayName
:
string
|
null
;
nameArabic
:
string
|
null
;
avatar
:
string
|
null
;
role
:
string
;
status
:
string
;
contractorType
:
string
|
null
;
department
:
string
|
null
;
title
:
string
|
null
;
phone
:
string
|
null
;
timezone
:
string
|
null
;
bio
:
string
|
null
;
startDate
:
string
|
null
;
lastLoginAt
:
string
|
null
;
createdAt
:
string
;
}
export
class
UserProfileResponseDto
extends
UserResponseDto
{
nationalId
:
string
|
null
;
dateOfBirth
:
string
|
null
;
phoneSecondary
:
string
|
null
;
address
:
string
|
null
;
emergencyContactName
:
string
|
null
;
emergencyContactPhone
:
string
|
null
;
emergencyContactRelationship
:
string
|
null
;
bankName
:
string
|
null
;
bankAccountNumber
:
string
|
null
;
bankAccountHolderName
:
string
|
null
;
taxRegistrationNumber
:
string
|
null
;
weeklySchedule
:
Record
<
string
,
string
>
|
null
;
baseSalaryPiasters
:
number
;
actualSalaryPiasters
:
number
;
contractStartDate
:
string
|
null
;
contractEndDate
:
string
|
null
;
assignedProjectLeaderId
:
string
|
null
;
forcePasswordChange
:
boolean
;
}
export
class
ContractorDirectoryEntryDto
{
id
:
string
;
firstName
:
string
;
lastName
:
string
;
displayName
:
string
|
null
;
avatar
:
string
|
null
;
role
:
string
;
contractorType
:
string
|
null
;
department
:
string
|
null
;
title
:
string
|
null
;
status
:
string
;
}
\ No newline at end of file
backend/src/modules/users/users.controller.ts
0 → 100644
View file @
f9cd0fe6
import
{
Controller
,
Get
,
Post
,
Put
,
Delete
,
Body
,
Param
,
Query
,
HttpCode
,
HttpStatus
,
}
from
'@nestjs/common'
;
import
{
UsersService
}
from
'./users.service'
;
import
{
CreateUserDto
}
from
'./dto/create-user.dto'
;
import
{
UpdateUserDto
}
from
'./dto/update-user.dto'
;
import
{
UserFilterDto
}
from
'./dto/user-filter.dto'
;
import
{
AddPrivateNoteDto
,
ChangeStatusDto
,
SetActualSalaryDto
}
from
'./dto/reset-password.dto'
;
import
{
CurrentUser
,
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
import
{
Roles
}
from
'../../common/decorators/roles.decorator'
;
@
Controller
(
'users'
)
export
class
UsersController
{
constructor
(
private
readonly
usersService
:
UsersService
)
{}
@
Post
()
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
create
(@
Body
()
dto
:
CreateUserDto
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
usersService
.
create
(
dto
,
user
);
}
@
Get
()
async
findAll
(@
Query
()
filter
:
UserFilterDto
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
usersService
.
findAll
(
filter
,
user
);
}
@
Get
(
'directory'
)
async
getDirectory
(@
Query
()
filter
:
UserFilterDto
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
usersService
.
getDirectory
(
filter
,
user
);
}
@
Get
(
':id'
)
async
findById
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
usersService
.
findById
(
id
,
user
);
}
@
Put
(
':id'
)
async
update
(
@
Param
(
'id'
)
id
:
string
,
@
Body
()
dto
:
UpdateUserDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
usersService
.
update
(
id
,
dto
,
user
);
}
@
Delete
(
':id'
)
@
Roles
(
'SUPER_ADMIN'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
softDelete
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
await
this
.
usersService
.
softDelete
(
id
,
user
);
return
{
message
:
'User deleted successfully'
};
}
@
Post
(
':id/reset-password'
)
@
Roles
(
'SUPER_ADMIN'
)
async
resetPassword
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
usersService
.
resetPassword
(
id
,
user
);
}
@
Post
(
':id/force-logout'
)
@
Roles
(
'SUPER_ADMIN'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
forceLogout
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
await
this
.
usersService
.
forceLogout
(
id
,
user
);
return
{
message
:
'User sessions revoked'
};
}
@
Post
(
'force-logout-all'
)
@
Roles
(
'SUPER_ADMIN'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
forceLogoutAll
(@
CurrentUser
()
user
:
RequestUser
)
{
const
result
=
await
this
.
usersService
.
forceLogoutAll
(
user
);
return
{
message
:
`
${
result
.
count
}
sessions revoked`
};
}
@
Post
(
':id/unlock'
)
@
Roles
(
'SUPER_ADMIN'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
unlockAccount
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
await
this
.
usersService
.
unlockAccount
(
id
,
user
);
return
{
message
:
'Account unlocked'
};
}
@
Get
(
':id/sessions'
)
async
getUserSessions
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
usersService
.
getUserSessions
(
id
,
user
);
}
@
Delete
(
':id/sessions/:sessionId'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
revokeSession
(
@
Param
(
'id'
)
userId
:
string
,
@
Param
(
'sessionId'
)
sessionId
:
string
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
await
this
.
usersService
.
revokeSession
(
userId
,
sessionId
,
user
);
return
{
message
:
'Session revoked'
};
}
@
Delete
(
':id/sessions'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
revokeAllOtherSessions
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
const
result
=
await
this
.
usersService
.
revokeAllOtherSessions
(
id
,
user
);
return
{
message
:
`
${
result
.
count
}
sessions revoked`
};
}
@
Put
(
':id/status'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
changeStatus
(
@
Param
(
'id'
)
id
:
string
,
@
Body
()
dto
:
ChangeStatusDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
usersService
.
changeStatus
(
id
,
dto
.
status
,
dto
.
reason
,
user
);
}
@
Put
(
':id/salary'
)
@
Roles
(
'SUPER_ADMIN'
)
async
setActualSalary
(
@
Param
(
'id'
)
id
:
string
,
@
Body
()
dto
:
SetActualSalaryDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
usersService
.
setActualSalary
(
id
,
dto
.
actualSalaryPiasters
,
dto
.
reason
,
user
);
}
@
Post
(
':id/notes'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
addPrivateNote
(
@
Param
(
'id'
)
id
:
string
,
@
Body
()
dto
:
AddPrivateNoteDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
usersService
.
addPrivateNote
(
id
,
dto
,
user
);
}
@
Get
(
':id/notes'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
getPrivateNotes
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
usersService
.
getPrivateNotes
(
id
,
user
);
}
}
\ No newline at end of file
backend/src/modules/users/users.module.ts
0 → 100644
View file @
f9cd0fe6
import
{
Module
}
from
'@nestjs/common'
;
import
{
UsersController
}
from
'./users.controller'
;
import
{
UsersService
}
from
'./users.service'
;
@
Module
({
controllers
:
[
UsersController
],
providers
:
[
UsersService
],
exports
:
[
UsersService
],
})
export
class
UsersModule
{}
\ No newline at end of file
backend/src/modules/users/users.service.ts
0 → 100644
View file @
f9cd0fe6
import
{
Injectable
,
NotFoundException
,
ConflictException
,
ForbiddenException
,
BadRequestException
,
Logger
,
}
from
'@nestjs/common'
;
import
*
as
bcrypt
from
'bcrypt'
;
import
*
as
crypto
from
'crypto'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
CreateUserDto
}
from
'./dto/create-user.dto'
;
import
{
UpdateUserDto
}
from
'./dto/update-user.dto'
;
import
{
UserFilterDto
}
from
'./dto/user-filter.dto'
;
import
{
AddPrivateNoteDto
}
from
'./dto/reset-password.dto'
;
import
{
getSkip
,
buildPaginatedResponse
,
PaginatedResult
,
}
from
'../../common/utils/pagination.util'
;
import
{
calculateBaseSalaryPiasters
,
}
from
'../../common/utils/salary.util'
;
import
{
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
@
Injectable
()
export
class
UsersService
{
private
readonly
logger
=
new
Logger
(
UsersService
.
name
);
private
readonly
BCRYPT_ROUNDS
=
12
;
constructor
(
private
readonly
prisma
:
PrismaService
)
{}
async
create
(
dto
:
CreateUserDto
,
createdBy
:
RequestUser
):
Promise
<
any
>
{
if
(
createdBy
.
role
!==
'SUPER_ADMIN'
&&
createdBy
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can create users'
);
}
if
(
createdBy
.
role
===
'ADMIN'
&&
(
dto
.
role
===
'SUPER_ADMIN'
||
dto
.
role
===
'ADMIN'
))
{
throw
new
ForbiddenException
(
'Admin cannot create Super Admin or Admin accounts'
);
}
const
existingEmail
=
await
this
.
prisma
.
user
.
findUnique
({
where
:
{
email
:
dto
.
email
}
});
if
(
existingEmail
)
{
throw
new
ConflictException
(
'Email already in use'
);
}
const
existingUsername
=
await
this
.
prisma
.
user
.
findUnique
({
where
:
{
username
:
dto
.
username
}
});
if
(
existingUsername
)
{
throw
new
ConflictException
(
'Username already in use'
);
}
if
(
dto
.
nationalId
)
{
const
existingNationalId
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
nationalId
:
dto
.
nationalId
},
});
if
(
existingNationalId
)
{
throw
new
ConflictException
(
'National ID already in use'
);
}
}
if
(
dto
.
phone
)
{
const
existingPhone
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
phone
:
dto
.
phone
}
});
if
(
existingPhone
)
{
throw
new
ConflictException
(
'Phone number already in use'
);
}
}
const
passwordHash
=
await
bcrypt
.
hash
(
dto
.
password
,
this
.
BCRYPT_ROUNDS
);
const
user
=
await
this
.
prisma
.
user
.
create
({
data
:
{
email
:
dto
.
email
,
username
:
dto
.
username
,
firstName
:
dto
.
firstName
,
lastName
:
dto
.
lastName
,
displayName
:
dto
.
displayName
||
null
,
nameArabic
:
dto
.
nameArabic
||
null
,
nationalId
:
dto
.
nationalId
||
null
,
dateOfBirth
:
dto
.
dateOfBirth
?
new
Date
(
dto
.
dateOfBirth
)
:
null
,
phone
:
dto
.
phone
||
null
,
phoneSecondary
:
dto
.
phoneSecondary
||
null
,
address
:
dto
.
address
||
null
,
emergencyContactName
:
dto
.
emergencyContactName
||
null
,
emergencyContactPhone
:
dto
.
emergencyContactPhone
||
null
,
emergencyContactRelationship
:
dto
.
emergencyContactRelationship
||
null
,
bankName
:
dto
.
bankName
||
null
,
bankAccountNumber
:
dto
.
bankAccountNumber
||
null
,
bankAccountHolderName
:
dto
.
bankAccountHolderName
||
null
,
taxRegistrationNumber
:
dto
.
taxRegistrationNumber
||
null
,
passwordHash
,
role
:
dto
.
role
,
status
:
'ACTIVE'
,
contractorType
:
dto
.
contractorType
||
null
,
department
:
dto
.
department
||
null
,
title
:
dto
.
title
||
null
,
bio
:
dto
.
bio
||
null
,
timezone
:
dto
.
timezone
||
'Africa/Cairo'
,
forcePasswordChange
:
true
,
},
});
this
.
logger
.
log
(
`User
${
user
.
username
}
created by
${
createdBy
.
email
}
`
);
return
this
.
sanitizeUser
(
user
,
createdBy
.
role
);
}
async
findAll
(
filter
:
UserFilterDto
,
currentUser
:
RequestUser
):
Promise
<
PaginatedResult
<
any
>>
{
const
page
=
filter
.
page
||
1
;
const
limit
=
filter
.
limit
||
20
;
const
where
:
any
=
{
deletedAt
:
null
};
if
(
filter
.
role
)
where
.
role
=
filter
.
role
;
if
(
filter
.
status
)
where
.
status
=
filter
.
status
;
if
(
filter
.
contractorType
)
where
.
contractorType
=
filter
.
contractorType
;
if
(
filter
.
department
)
where
.
department
=
filter
.
department
;
if
(
filter
.
search
)
{
where
.
OR
=
[
{
firstName
:
{
contains
:
filter
.
search
,
mode
:
'insensitive'
}
},
{
lastName
:
{
contains
:
filter
.
search
,
mode
:
'insensitive'
}
},
{
username
:
{
contains
:
filter
.
search
,
mode
:
'insensitive'
}
},
{
email
:
{
contains
:
filter
.
search
,
mode
:
'insensitive'
}
},
{
displayName
:
{
contains
:
filter
.
search
,
mode
:
'insensitive'
}
},
{
nameArabic
:
{
contains
:
filter
.
search
,
mode
:
'insensitive'
}
},
];
}
if
(
filter
.
boardId
)
{
where
.
boardMemberships
=
{
some
:
{
boardId
:
filter
.
boardId
}
};
}
// TEAM_LEAD can only see their team members
if
(
currentUser
.
role
===
'TEAM_LEAD'
)
{
where
.
OR
=
[
{
assignedProjectLeaderId
:
currentUser
.
id
},
{
id
:
currentUser
.
id
},
];
}
const
[
users
,
total
]
=
await
Promise
.
all
([
this
.
prisma
.
user
.
findMany
({
where
,
skip
:
getSkip
(
page
,
limit
),
take
:
limit
,
orderBy
:
{
[
filter
.
sortBy
||
'createdAt'
]:
filter
.
sortOrder
||
'desc'
},
}),
this
.
prisma
.
user
.
count
({
where
}),
]);
const
sanitized
=
users
.
map
((
u
)
=>
this
.
sanitizeUser
(
u
,
currentUser
.
role
));
return
buildPaginatedResponse
(
sanitized
,
total
,
{
page
,
limit
,
sortOrder
:
filter
.
sortOrder
||
'desc'
});
}
async
findById
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
const
user
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
id
,
deletedAt
:
null
},
});
if
(
!
user
)
{
throw
new
NotFoundException
(
'User not found'
);
}
// Contractors can only view their own full profile
if
(
currentUser
.
role
===
'CONTRACTOR'
&&
currentUser
.
id
!==
id
)
{
return
this
.
sanitizeUserForDirectory
(
user
);
}
// TEAM_LEAD can only view their team members' profiles
if
(
currentUser
.
role
===
'TEAM_LEAD'
&&
currentUser
.
id
!==
id
)
{
if
(
user
.
assignedProjectLeaderId
!==
currentUser
.
id
)
{
return
this
.
sanitizeUserForDirectory
(
user
);
}
}
return
this
.
sanitizeUser
(
user
,
currentUser
.
role
);
}
async
update
(
id
:
string
,
dto
:
UpdateUserDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
const
user
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
id
,
deletedAt
:
null
}
});
if
(
!
user
)
{
throw
new
NotFoundException
(
'User not found'
);
}
this
.
enforceUpdatePermissions
(
user
,
dto
,
currentUser
);
// Uniqueness checks
if
(
dto
.
email
&&
dto
.
email
!==
user
.
email
)
{
const
existing
=
await
this
.
prisma
.
user
.
findUnique
({
where
:
{
email
:
dto
.
email
}
});
if
(
existing
)
throw
new
ConflictException
(
'Email already in use'
);
}
if
(
dto
.
username
&&
dto
.
username
!==
user
.
username
)
{
const
existing
=
await
this
.
prisma
.
user
.
findUnique
({
where
:
{
username
:
dto
.
username
}
});
if
(
existing
)
throw
new
ConflictException
(
'Username already in use'
);
}
// Handle salary change with audit
let
salaryChanged
=
false
;
let
oldSalary
=
user
.
actualSalaryPiasters
;
if
(
dto
.
actualSalaryPiasters
!==
undefined
&&
dto
.
actualSalaryPiasters
!==
user
.
actualSalaryPiasters
)
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can change actual salary'
);
}
if
(
dto
.
actualSalaryPiasters
<
user
.
baseSalaryPiasters
)
{
this
.
logger
.
warn
(
`Actual salary (
${
dto
.
actualSalaryPiasters
}
) is below base salary (
${
user
.
baseSalaryPiasters
}
) for user
${
id
}
`
,
);
}
salaryChanged
=
true
;
}
// Handle schedule change → recalculate base salary
let
baseSalaryUpdate
:
number
|
undefined
;
if
(
dto
.
weeklySchedule
&&
currentUser
.
role
===
'SUPER_ADMIN'
)
{
const
settings
=
await
this
.
getSalaryRates
();
baseSalaryUpdate
=
calculateBaseSalaryPiasters
(
dto
.
weeklySchedule
,
user
.
contractorType
||
'FULL_TIME'
,
settings
,
);
}
const
updateData
:
any
=
{};
// Map only provided fields
const
directFields
=
[
'email'
,
'username'
,
'firstName'
,
'lastName'
,
'displayName'
,
'nameArabic'
,
'nationalId'
,
'phone'
,
'phoneSecondary'
,
'address'
,
'avatar'
,
'emergencyContactName'
,
'emergencyContactPhone'
,
'emergencyContactRelationship'
,
'bankName'
,
'bankAccountNumber'
,
'bankAccountHolderName'
,
'taxRegistrationNumber'
,
'role'
,
'status'
,
'contractorType'
,
'department'
,
'title'
,
'bio'
,
'timezone'
,
'assignedProjectLeaderId'
,
];
for
(
const
field
of
directFields
)
{
if
((
dto
as
any
)[
field
]
!==
undefined
)
{
updateData
[
field
]
=
(
dto
as
any
)[
field
];
}
}
if
(
dto
.
dateOfBirth
!==
undefined
)
updateData
.
dateOfBirth
=
new
Date
(
dto
.
dateOfBirth
);
if
(
dto
.
startDate
!==
undefined
)
updateData
.
startDate
=
new
Date
(
dto
.
startDate
);
if
(
dto
.
contractStartDate
!==
undefined
)
updateData
.
contractStartDate
=
new
Date
(
dto
.
contractStartDate
);
if
(
dto
.
contractEndDate
!==
undefined
)
updateData
.
contractEndDate
=
dto
.
contractEndDate
?
new
Date
(
dto
.
contractEndDate
)
:
null
;
if
(
dto
.
weeklySchedule
!==
undefined
)
updateData
.
weeklySchedule
=
dto
.
weeklySchedule
;
if
(
dto
.
actualSalaryPiasters
!==
undefined
)
updateData
.
actualSalaryPiasters
=
dto
.
actualSalaryPiasters
;
if
(
baseSalaryUpdate
!==
undefined
)
updateData
.
baseSalaryPiasters
=
baseSalaryUpdate
;
const
updated
=
await
this
.
prisma
.
user
.
update
({
where
:
{
id
},
data
:
updateData
,
});
// Log salary change
if
(
salaryChanged
)
{
await
this
.
prisma
.
salaryChangeLog
.
create
({
data
:
{
userId
:
id
,
oldSalaryPiasters
:
oldSalary
,
newSalaryPiasters
:
dto
.
actualSalaryPiasters
!
,
reason
:
dto
.
salaryChangeReason
||
'No reason provided'
,
changedById
:
currentUser
.
id
,
},
}).
catch
((
err
)
=>
{
this
.
logger
.
warn
(
`Failed to log salary change:
${
err
.
message
}
. Table may not exist yet.`
);
});
}
return
this
.
sanitizeUser
(
updated
,
currentUser
.
role
);
}
async
softDelete
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
void
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can delete users'
);
}
const
user
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
id
,
deletedAt
:
null
}
});
if
(
!
user
)
{
throw
new
NotFoundException
(
'User not found'
);
}
if
(
user
.
id
===
currentUser
.
id
)
{
throw
new
BadRequestException
(
'Cannot delete your own account'
);
}
await
this
.
prisma
.
user
.
update
({
where
:
{
id
},
data
:
{
deletedAt
:
new
Date
(),
status
:
'OFFBOARDED'
},
});
// Revoke all sessions
await
this
.
prisma
.
session
.
updateMany
({
where
:
{
userId
:
id
,
revokedAt
:
null
},
data
:
{
revokedAt
:
new
Date
()
},
});
this
.
logger
.
log
(
`User
${
id
}
soft-deleted by
${
currentUser
.
email
}
`
);
}
async
resetPassword
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
{
temporaryPassword
:
string
}
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can reset passwords'
);
}
const
user
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
id
,
deletedAt
:
null
}
});
if
(
!
user
)
{
throw
new
NotFoundException
(
'User not found'
);
}
const
temporaryPassword
=
this
.
generateTemporaryPassword
();
const
passwordHash
=
await
bcrypt
.
hash
(
temporaryPassword
,
this
.
BCRYPT_ROUNDS
);
await
this
.
prisma
.
user
.
update
({
where
:
{
id
},
data
:
{
passwordHash
,
forcePasswordChange
:
true
,
failedLoginAttempts
:
0
,
lockedUntil
:
null
,
},
});
this
.
logger
.
log
(
`Password reset for user
${
id
}
by
${
currentUser
.
email
}
`
);
return
{
temporaryPassword
};
}
async
forceLogout
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
void
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can force logout users'
);
}
const
user
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
id
,
deletedAt
:
null
}
});
if
(
!
user
)
{
throw
new
NotFoundException
(
'User not found'
);
}
const
result
=
await
this
.
prisma
.
session
.
updateMany
({
where
:
{
userId
:
id
,
revokedAt
:
null
},
data
:
{
revokedAt
:
new
Date
()
},
});
this
.
logger
.
log
(
`Force logged out user
${
id
}
(
${
result
.
count
}
sessions revoked) by
${
currentUser
.
email
}
`
);
}
async
forceLogoutAll
(
currentUser
:
RequestUser
):
Promise
<
{
count
:
number
}
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can force logout all users'
);
}
const
result
=
await
this
.
prisma
.
session
.
updateMany
({
where
:
{
revokedAt
:
null
,
userId
:
{
not
:
currentUser
.
id
},
},
data
:
{
revokedAt
:
new
Date
()
},
});
this
.
logger
.
log
(
`Force logged out ALL users (
${
result
.
count
}
sessions) by
${
currentUser
.
email
}
`
);
return
{
count
:
result
.
count
};
}
async
getUserSessions
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
[]
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
id
!==
id
)
{
throw
new
ForbiddenException
(
'You can only view your own sessions'
);
}
const
sessions
=
await
this
.
prisma
.
session
.
findMany
({
where
:
{
userId
:
id
},
orderBy
:
{
createdAt
:
'desc'
},
take
:
50
,
select
:
{
id
:
true
,
ipAddress
:
true
,
userAgent
:
true
,
createdAt
:
true
,
lastActiveAt
:
true
,
expiresAt
:
true
,
revokedAt
:
true
,
},
});
return
sessions
.
map
((
s
)
=>
({
...
s
,
isActive
:
!
s
.
revokedAt
&&
s
.
expiresAt
>
new
Date
(),
}));
}
async
revokeSession
(
userId
:
string
,
sessionId
:
string
,
currentUser
:
RequestUser
):
Promise
<
void
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
id
!==
userId
)
{
throw
new
ForbiddenException
(
'You can only manage your own sessions'
);
}
const
session
=
await
this
.
prisma
.
session
.
findFirst
({
where
:
{
id
:
sessionId
,
userId
,
revokedAt
:
null
},
});
if
(
!
session
)
{
throw
new
NotFoundException
(
'Session not found or already revoked'
);
}
// Don't allow revoking your own current session via this endpoint
if
(
sessionId
===
currentUser
.
sessionId
)
{
throw
new
BadRequestException
(
'Cannot revoke your current session. Use logout instead.'
);
}
await
this
.
prisma
.
session
.
update
({
where
:
{
id
:
sessionId
},
data
:
{
revokedAt
:
new
Date
()
},
});
}
async
revokeAllOtherSessions
(
userId
:
string
,
currentUser
:
RequestUser
):
Promise
<
{
count
:
number
}
>
{
if
(
currentUser
.
id
!==
userId
&&
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'You can only manage your own sessions'
);
}
const
result
=
await
this
.
prisma
.
session
.
updateMany
({
where
:
{
userId
,
revokedAt
:
null
,
id
:
{
not
:
currentUser
.
sessionId
},
},
data
:
{
revokedAt
:
new
Date
()
},
});
return
{
count
:
result
.
count
};
}
async
getDirectory
(
filter
:
UserFilterDto
,
currentUser
:
RequestUser
):
Promise
<
PaginatedResult
<
any
>>
{
const
page
=
filter
.
page
||
1
;
const
limit
=
filter
.
limit
||
50
;
const
where
:
any
=
{
deletedAt
:
null
,
status
:
{
notIn
:
[
'OFFBOARDED'
,
'INVITED'
]
},
};
if
(
filter
.
search
)
{
where
.
OR
=
[
{
firstName
:
{
contains
:
filter
.
search
,
mode
:
'insensitive'
}
},
{
lastName
:
{
contains
:
filter
.
search
,
mode
:
'insensitive'
}
},
{
username
:
{
contains
:
filter
.
search
,
mode
:
'insensitive'
}
},
{
displayName
:
{
contains
:
filter
.
search
,
mode
:
'insensitive'
}
},
];
}
if
(
filter
.
role
)
where
.
role
=
filter
.
role
;
if
(
filter
.
status
)
where
.
status
=
filter
.
status
;
if
(
filter
.
contractorType
)
where
.
contractorType
=
filter
.
contractorType
;
const
[
users
,
total
]
=
await
Promise
.
all
([
this
.
prisma
.
user
.
findMany
({
where
,
skip
:
getSkip
(
page
,
limit
),
take
:
limit
,
orderBy
:
{
firstName
:
'asc'
},
}),
this
.
prisma
.
user
.
count
({
where
}),
]);
const
sanitized
=
users
.
map
((
u
)
=>
this
.
sanitizeUserForDirectory
(
u
,
currentUser
));
return
buildPaginatedResponse
(
sanitized
,
total
,
{
page
,
limit
,
sortOrder
:
'asc'
});
}
async
addPrivateNote
(
userId
:
string
,
dto
:
AddPrivateNoteDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can add private notes'
);
}
const
user
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
id
:
userId
,
deletedAt
:
null
}
});
if
(
!
user
)
{
throw
new
NotFoundException
(
'User not found'
);
}
const
note
=
await
this
.
prisma
.
privateNote
.
create
({
data
:
{
userId
,
content
:
dto
.
content
,
authorId
:
currentUser
.
id
,
},
}).
catch
(()
=>
{
// If PrivateNote table doesn't exist yet, store as JSON on user
this
.
logger
.
warn
(
'PrivateNote table may not exist. Skipping.'
);
return
null
;
});
return
note
;
}
async
getPrivateNotes
(
userId
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
[]
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can view private notes'
);
}
try
{
return
await
this
.
prisma
.
privateNote
.
findMany
({
where
:
{
userId
},
orderBy
:
{
createdAt
:
'desc'
},
include
:
{
author
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
username
:
true
},
},
},
});
}
catch
{
return
[];
}
}
async
changeStatus
(
id
:
string
,
status
:
string
,
reason
:
string
|
undefined
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can change user status'
);
}
if
(
currentUser
.
role
===
'ADMIN'
&&
(
status
===
'OFFBOARDED'
))
{
throw
new
ForbiddenException
(
'Only Super Admin can terminate users'
);
}
const
user
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
id
,
deletedAt
:
null
}
});
if
(
!
user
)
{
throw
new
NotFoundException
(
'User not found'
);
}
const
oldStatus
=
user
.
status
;
const
updated
=
await
this
.
prisma
.
user
.
update
({
where
:
{
id
},
data
:
{
status
},
});
// Log status change
try
{
await
this
.
prisma
.
statusChangeLog
.
create
({
data
:
{
userId
:
id
,
fromStatus
:
oldStatus
,
toStatus
:
status
,
reason
:
reason
||
null
,
changedById
:
currentUser
.
id
,
},
});
}
catch
{
this
.
logger
.
warn
(
'StatusChangeLog table may not exist yet.'
);
}
// If offboarded, revoke all sessions
if
(
status
===
'OFFBOARDED'
)
{
await
this
.
prisma
.
session
.
updateMany
({
where
:
{
userId
:
id
,
revokedAt
:
null
},
data
:
{
revokedAt
:
new
Date
()
},
});
}
return
this
.
sanitizeUser
(
updated
,
currentUser
.
role
);
}
async
setActualSalary
(
id
:
string
,
salaryPiasters
:
number
,
reason
:
string
|
undefined
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can set actual salary'
);
}
const
user
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
id
,
deletedAt
:
null
}
});
if
(
!
user
)
{
throw
new
NotFoundException
(
'User not found'
);
}
const
oldSalary
=
user
.
actualSalaryPiasters
;
const
updated
=
await
this
.
prisma
.
user
.
update
({
where
:
{
id
},
data
:
{
actualSalaryPiasters
:
salaryPiasters
},
});
// Log salary change
try
{
await
this
.
prisma
.
salaryChangeLog
.
create
({
data
:
{
userId
:
id
,
oldSalaryPiasters
:
oldSalary
,
newSalaryPiasters
:
salaryPiasters
,
reason
:
reason
||
'No reason provided'
,
changedById
:
currentUser
.
id
,
},
});
}
catch
{
this
.
logger
.
warn
(
'SalaryChangeLog table may not exist yet.'
);
}
this
.
logger
.
log
(
`Salary for user
${
id
}
changed from
${
oldSalary
}
to
${
salaryPiasters
}
by
${
currentUser
.
email
}
`
,
);
return
this
.
sanitizeUser
(
updated
,
currentUser
.
role
);
}
async
unlockAccount
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
void
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can unlock accounts'
);
}
await
this
.
prisma
.
user
.
update
({
where
:
{
id
},
data
:
{
failedLoginAttempts
:
0
,
lockedUntil
:
null
},
});
this
.
logger
.
log
(
`Account
${
id
}
unlocked by
${
currentUser
.
email
}
`
);
}
// ─── HELPERS ─────────────────────────────────────────────────────────────
private
enforceUpdatePermissions
(
user
:
any
,
dto
:
UpdateUserDto
,
currentUser
:
RequestUser
):
void
{
const
isSelf
=
currentUser
.
id
===
user
.
id
;
const
isSA
=
currentUser
.
role
===
'SUPER_ADMIN'
;
const
isAdmin
=
currentUser
.
role
===
'ADMIN'
;
if
(
isSA
)
return
;
// SA can edit anything
if
(
isAdmin
)
{
const
forbidden
=
[
'role'
,
'actualSalaryPiasters'
,
'nationalId'
,
'dateOfBirth'
];
if
(
dto
.
contractorType
!==
undefined
)
{
throw
new
ForbiddenException
(
'Admin cannot change contractor type'
);
}
for
(
const
field
of
forbidden
)
{
if
((
dto
as
any
)[
field
]
!==
undefined
)
{
throw
new
ForbiddenException
(
`Admin cannot change
${
field
}
`
);
}
}
return
;
}
if
(
isSelf
)
{
const
allowedSelfFields
=
[
'phone'
,
'phoneSecondary'
,
'address'
,
'avatar'
,
'emergencyContactName'
,
'emergencyContactPhone'
,
'emergencyContactRelationship'
,
'bankName'
,
'bankAccountNumber'
,
'bankAccountHolderName'
,
'displayName'
,
'bio'
,
'timezone'
,
];
for
(
const
key
of
Object
.
keys
(
dto
))
{
if
(
!
allowedSelfFields
.
includes
(
key
)
&&
(
dto
as
any
)[
key
]
!==
undefined
)
{
throw
new
ForbiddenException
(
`You cannot change the field:
${
key
}
`
);
}
}
return
;
}
throw
new
ForbiddenException
(
'You do not have permission to edit this user'
);
}
private
sanitizeUser
(
user
:
any
,
viewerRole
:
string
):
any
{
const
base
:
any
=
{
id
:
user
.
id
,
email
:
user
.
email
,
username
:
user
.
username
,
firstName
:
user
.
firstName
,
lastName
:
user
.
lastName
,
displayName
:
user
.
displayName
,
nameArabic
:
user
.
nameArabic
,
avatar
:
user
.
avatar
,
role
:
user
.
role
,
status
:
user
.
status
,
contractorType
:
user
.
contractorType
,
department
:
user
.
department
,
title
:
user
.
title
,
bio
:
user
.
bio
,
timezone
:
user
.
timezone
,
startDate
:
user
.
startDate
,
lastLoginAt
:
user
.
lastLoginAt
,
createdAt
:
user
.
createdAt
,
updatedAt
:
user
.
updatedAt
,
};
if
(
viewerRole
===
'SUPER_ADMIN'
||
viewerRole
===
'ADMIN'
)
{
base
.
nationalId
=
user
.
nationalId
;
base
.
dateOfBirth
=
user
.
dateOfBirth
;
base
.
phone
=
user
.
phone
;
base
.
phoneSecondary
=
user
.
phoneSecondary
;
base
.
address
=
user
.
address
;
base
.
emergencyContactName
=
user
.
emergencyContactName
;
base
.
emergencyContactPhone
=
user
.
emergencyContactPhone
;
base
.
emergencyContactRelationship
=
user
.
emergencyContactRelationship
;
base
.
bankName
=
user
.
bankName
;
base
.
bankAccountNumber
=
user
.
bankAccountNumber
;
base
.
bankAccountHolderName
=
user
.
bankAccountHolderName
;
base
.
taxRegistrationNumber
=
user
.
taxRegistrationNumber
;
base
.
weeklySchedule
=
user
.
weeklySchedule
;
base
.
baseSalaryPiasters
=
user
.
baseSalaryPiasters
;
base
.
actualSalaryPiasters
=
user
.
actualSalaryPiasters
;
base
.
contractStartDate
=
user
.
contractStartDate
;
base
.
contractEndDate
=
user
.
contractEndDate
;
base
.
assignedProjectLeaderId
=
user
.
assignedProjectLeaderId
;
base
.
forcePasswordChange
=
user
.
forcePasswordChange
;
base
.
failedLoginAttempts
=
user
.
failedLoginAttempts
;
base
.
lockedUntil
=
user
.
lockedUntil
;
}
// Never expose password hash
return
base
;
}
private
sanitizeUserForDirectory
(
user
:
any
,
currentUser
?:
RequestUser
):
any
{
const
entry
:
any
=
{
id
:
user
.
id
,
firstName
:
user
.
firstName
,
lastName
:
user
.
lastName
,
displayName
:
user
.
displayName
,
avatar
:
user
.
avatar
,
role
:
user
.
role
,
contractorType
:
user
.
contractorType
,
department
:
user
.
department
,
title
:
user
.
title
,
status
:
user
.
status
,
};
if
(
currentUser
)
{
const
isAdminPlus
=
currentUser
.
role
===
'SUPER_ADMIN'
||
currentUser
.
role
===
'ADMIN'
;
if
(
isAdminPlus
)
{
entry
.
phone
=
user
.
phone
;
entry
.
email
=
user
.
email
;
entry
.
actualSalaryPiasters
=
user
.
actualSalaryPiasters
;
}
if
(
currentUser
.
role
===
'TEAM_LEAD'
&&
user
.
assignedProjectLeaderId
===
currentUser
.
id
)
{
entry
.
phone
=
user
.
phone
;
entry
.
email
=
user
.
email
;
}
}
return
entry
;
}
private
generateTemporaryPassword
():
string
{
const
chars
=
'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%&*'
;
let
password
=
''
;
const
bytes
=
crypto
.
randomBytes
(
12
);
for
(
let
i
=
0
;
i
<
12
;
i
++
)
{
password
+=
chars
[
bytes
[
i
]
%
chars
.
length
];
}
return
password
;
}
private
async
getSalaryRates
():
Promise
<
{
fullTimeInOffice
:
number
;
fullTimeRemote
:
number
;
internInOffice
:
number
;
internRemote
:
number
;
}
>
{
const
settings
=
await
this
.
prisma
.
setting
.
findMany
({
where
:
{
key
:
{
in
:
[
'fullTimeInOfficeRate'
,
'fullTimeRemoteRate'
,
'internInOfficeRate'
,
'internRemoteRate'
,
],
},
},
});
const
map
:
Record
<
string
,
any
>
=
{};
for
(
const
s
of
settings
)
{
map
[
s
.
key
]
=
s
.
value
;
}
return
{
fullTimeInOffice
:
(
map
[
'fullTimeInOfficeRate'
]
as
number
)
||
240000
,
fullTimeRemote
:
(
map
[
'fullTimeRemoteRate'
]
as
number
)
||
160000
,
internInOffice
:
(
map
[
'internInOfficeRate'
]
as
number
)
||
100000
,
internRemote
:
(
map
[
'internRemoteRate'
]
as
number
)
||
50000
,
};
}
}
\ No newline at end of file
prisma/schema-additions.prisma
0 → 100644
View file @
f9cd0fe6
//
───
ADD
TO
User
MODEL
──────────────────────────────────────────
//
These
fields
must
exist
on
the
User
model
:
//
nameArabic
String
?
//
nationalId
String
?
@
unique
//
dateOfBirth
DateTime
?
//
phone
String
?
@
unique
//
phoneSecondary
String
?
//
address
String
?
//
emergencyContactName
String
?
//
emergencyContactPhone
String
?
//
emergencyContactRelationship
String
?
//
bankName
String
?
//
bankAccountNumber
String
?
//
bankAccountHolderName
String
?
//
taxRegistrationNumber
String
?
//
contractorType
String
?
//
department
String
?
//
title
String
?
//
bio
String
?
//
timezone
String
@
default
(
"Africa/Cairo"
)
//
weeklySchedule
Json
?
//
baseSalaryPiasters
Int
@
default
(
0
)
//
actualSalaryPiasters
Int
@
default
(
0
)
//
startDate
DateTime
?
//
contractStartDate
DateTime
?
//
contractEndDate
DateTime
?
//
assignedProjectLeaderId
String
?
//
onboardingChecklist
Json
?
//
deletedAt
DateTime
?
//
───
NEW
MODELS
─────────────────────────────────────────────────
model
Invite
{
id
String
@
id
@
default
(
uuid
())
code
String
@
unique
token
String
@
unique
contractorType
String
assignedProjectLeaderId
String
?
assignedBoardIds
String
[]
welcomeNote
String
?
expiresAt
DateTime
status
String
@
default
(
"ACTIVE"
)
//
ACTIVE
,
USED
,
EXPIRED
,
REVOKED
createdById
String
createdBy
User
@
relation
(
"InviteCreator"
,
fields
:
[
createdById
],
references
:
[
id
])
usedById
String
?
usedBy
User
?
@
relation
(
"InviteUsed"
,
fields
:
[
usedById
],
references
:
[
id
])
usedAt
DateTime
?
createdAt
DateTime
@
default
(
now
())
updatedAt
DateTime
@
updatedAt
@@
index
([
code
])
@@
index
([
token
])
@@
index
([
status
])
}
model
BoardMember
{
id
String
@
id
@
default
(
uuid
())
boardId
String
userId
String
role
String
@
default
(
"MEMBER"
)
//
OWNER
,
ADMIN
,
MEMBER
,
VIEWER
joinedAt
DateTime
@
default
(
now
())
createdAt
DateTime
@
default
(
now
())
updatedAt
DateTime
@
updatedAt
user
User
@
relation
(
fields
:
[
userId
],
references
:
[
id
],
onDelete
:
Cascade
)
@@
unique
([
boardId
,
userId
])
@@
index
([
boardId
])
@@
index
([
userId
])
}
model
Contract
{
id
String
@
id
@
default
(
uuid
())
userId
String
user
User
@
relation
(
fields
:
[
userId
],
references
:
[
id
],
onDelete
:
Cascade
)
contractType
String
contractText
String
signedAt
DateTime
signedFullName
String
acknowledgedClauses
String
[]
signatureIpAddress
String
signatureUserAgent
String
baseSalaryAtSigning
Int
scheduleAtSigning
Json
?
startDate
DateTime
endDate
DateTime
?
status
String
@
default
(
"ACTIVE"
)
//
DRAFT
,
ACTIVE
,
EXPIRED
,
TERMINATED
createdAt
DateTime
@
default
(
now
())
updatedAt
DateTime
@
updatedAt
@@
index
([
userId
])
@@
index
([
status
])
}
model
CompetencyRating
{
id
String
@
id
@
default
(
uuid
())
userId
String
competencyAreaId
String
type
String
//
SELF
,
PL_ASSESSMENT
level
Int
//
0
-
5
assessedAt
DateTime
@
default
(
now
())
createdAt
DateTime
@
default
(
now
())
updatedAt
DateTime
@
updatedAt
user
User
@
relation
(
fields
:
[
userId
],
references
:
[
id
],
onDelete
:
Cascade
)
competencyArea
CompetencyArea
@
relation
(
fields
:
[
competencyAreaId
],
references
:
[
id
])
@@
unique
([
userId
,
competencyAreaId
,
type
])
@@
index
([
userId
])
}
model
SalaryChangeLog
{
id
String
@
id
@
default
(
uuid
())
userId
String
oldSalaryPiasters
Int
newSalaryPiasters
Int
reason
String
changedById
String
createdAt
DateTime
@
default
(
now
())
@@
index
([
userId
])
}
model
StatusChangeLog
{
id
String
@
id
@
default
(
uuid
())
userId
String
fromStatus
String
toStatus
String
reason
String
?
changedById
String
createdAt
DateTime
@
default
(
now
())
@@
index
([
userId
])
}
model
PrivateNote
{
id
String
@
id
@
default
(
uuid
())
userId
String
content
String
authorId
String
author
User
@
relation
(
"NoteAuthor"
,
fields
:
[
authorId
],
references
:
[
id
])
createdAt
DateTime
@
default
(
now
())
updatedAt
DateTime
@
updatedAt
@@
index
([
userId
])
}
\ No newline at end of file
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment