db management fix
This commit is contained in:
@@ -58,7 +58,7 @@ NODE_ENV=production
|
|||||||
EMAIL_FROM=rentaldrivego@gmail.com
|
EMAIL_FROM=rentaldrivego@gmail.com
|
||||||
EMAIL_FROM_NAME=RentalDriveGo
|
EMAIL_FROM_NAME=RentalDriveGo
|
||||||
# Option A — Resend
|
# Option A — Resend
|
||||||
RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V
|
#RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V
|
||||||
# Option B — SMTP (Gmail)
|
# Option B — SMTP (Gmail)
|
||||||
MAIL_HOST=smtp.gmail.com
|
MAIL_HOST=smtp.gmail.com
|
||||||
MAIL_PORT=587
|
MAIL_PORT=587
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('../../lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
adminUser: {
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '../../lib/prisma'
|
||||||
|
import { findAdminByEmail } from './admin.repo'
|
||||||
|
|
||||||
|
describe('admin.repo', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('looks up admin email case-insensitively', async () => {
|
||||||
|
await findAdminByEmail('Admin@Example.com')
|
||||||
|
|
||||||
|
expect(prisma.adminUser.findFirst).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
email: {
|
||||||
|
equals: 'Admin@Example.com',
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -31,7 +31,14 @@ function parseOptionalDate(value?: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function findAdminByEmail(email: string) {
|
export function findAdminByEmail(email: string) {
|
||||||
return prisma.adminUser.findUnique({ where: { email } })
|
return prisma.adminUser.findFirst({
|
||||||
|
where: {
|
||||||
|
email: {
|
||||||
|
equals: email,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findAdminByIdOrThrow(id: string) {
|
export function findAdminByIdOrThrow(id: string) {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('./admin.repo', () => ({
|
||||||
|
findAdminByEmail: vi.fn(),
|
||||||
|
setAdminPasswordReset: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../services/notificationService', () => ({
|
||||||
|
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import * as repo from './admin.repo'
|
||||||
|
import { sendTransactionalEmail } from '../../services/notificationService'
|
||||||
|
import { forgotPassword } from './admin.service'
|
||||||
|
|
||||||
|
describe('admin.service forgotPassword', () => {
|
||||||
|
const originalAdminUrl = process.env.ADMIN_URL
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
process.env.ADMIN_URL = 'http://localhost:3002'
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
process.env.ADMIN_URL = originalAdminUrl
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends the reset email to the canonical stored admin address', async () => {
|
||||||
|
vi.mocked(repo.findAdminByEmail).mockResolvedValue({
|
||||||
|
id: 'admin_1',
|
||||||
|
email: 'admin@rentaldrivego.com',
|
||||||
|
firstName: 'Samir',
|
||||||
|
isActive: true,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
await forgotPassword('Admin@RentalDriveGo.com')
|
||||||
|
|
||||||
|
expect(repo.setAdminPasswordReset).toHaveBeenCalledWith(
|
||||||
|
'admin_1',
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(Date),
|
||||||
|
)
|
||||||
|
expect(sendTransactionalEmail).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: 'admin@rentaldrivego.com',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -93,7 +93,7 @@ export async function forgotPassword(email: string) {
|
|||||||
const resetUrl = `${adminUrl}/reset-password?token=${rawToken}`
|
const resetUrl = `${adminUrl}/reset-password?token=${rawToken}`
|
||||||
|
|
||||||
await sendTransactionalEmail({
|
await sendTransactionalEmail({
|
||||||
to: email,
|
to: admin.email,
|
||||||
subject: 'Reset your RentalDriveGo admin password',
|
subject: 'Reset your RentalDriveGo admin password',
|
||||||
html: `<p>Hi ${admin.firstName},</p><p><a href="${resetUrl}">Reset password</a></p><p>Expires in ${ADMIN_RESET_TTL_MINUTES} minutes.</p>`,
|
html: `<p>Hi ${admin.firstName},</p><p><a href="${resetUrl}">Reset password</a></p><p>Expires in ${ADMIN_RESET_TTL_MINUTES} minutes.</p>`,
|
||||||
text: `Hi ${admin.firstName},\n\nReset here: ${resetUrl}\n\nExpires in ${ADMIN_RESET_TTL_MINUTES} minutes.`,
|
text: `Hi ${admin.firstName},\n\nReset here: ${resetUrl}\n\nExpires in ${ADMIN_RESET_TTL_MINUTES} minutes.`,
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('../../lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
employee: {
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '../../lib/prisma'
|
||||||
|
import { findActiveEmployeeByEmail, findEmployeeWithCompanyByEmail } from './auth.employee.repo'
|
||||||
|
|
||||||
|
describe('auth.employee.repo', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('looks up employee login email case-insensitively', async () => {
|
||||||
|
await findEmployeeWithCompanyByEmail('Owner@Example.com')
|
||||||
|
|
||||||
|
expect(prisma.employee.findFirst).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
email: {
|
||||||
|
equals: 'Owner@Example.com',
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { company: true },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('looks up active employee reset email case-insensitively', async () => {
|
||||||
|
await findActiveEmployeeByEmail('Owner@Example.com')
|
||||||
|
|
||||||
|
expect(prisma.employee.findFirst).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
email: {
|
||||||
|
equals: 'Owner@Example.com',
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -9,14 +9,25 @@ export function findEmployeeWithCompanyById(id: string) {
|
|||||||
|
|
||||||
export function findEmployeeWithCompanyByEmail(email: string) {
|
export function findEmployeeWithCompanyByEmail(email: string) {
|
||||||
return prisma.employee.findFirst({
|
return prisma.employee.findFirst({
|
||||||
where: { email },
|
where: {
|
||||||
|
email: {
|
||||||
|
equals: email,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
include: { company: true },
|
include: { company: true },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findActiveEmployeeByEmail(email: string) {
|
export function findActiveEmployeeByEmail(email: string) {
|
||||||
return prisma.employee.findFirst({
|
return prisma.employee.findFirst({
|
||||||
where: { email, isActive: true },
|
where: {
|
||||||
|
email: {
|
||||||
|
equals: email,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('./auth.employee.repo', () => ({
|
||||||
|
findActiveEmployeeByEmail: vi.fn(),
|
||||||
|
setPasswordResetToken: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../services/notificationService', () => ({
|
||||||
|
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import * as repo from './auth.employee.repo'
|
||||||
|
import { sendTransactionalEmail } from '../../services/notificationService'
|
||||||
|
import { forgotPassword } from './auth.employee.service'
|
||||||
|
|
||||||
|
describe('auth.employee.service forgotPassword', () => {
|
||||||
|
const originalDashboardUrl = process.env.DASHBOARD_URL
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
process.env.DASHBOARD_URL = 'http://localhost:3001'
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
process.env.DASHBOARD_URL = originalDashboardUrl
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends the reset email to the canonical stored address', async () => {
|
||||||
|
vi.mocked(repo.findActiveEmployeeByEmail).mockResolvedValue({
|
||||||
|
id: 'emp_1',
|
||||||
|
email: 'owner@company.com',
|
||||||
|
firstName: 'Aya',
|
||||||
|
preferredLanguage: 'en',
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
await forgotPassword('Owner@Company.com')
|
||||||
|
|
||||||
|
expect(repo.setPasswordResetToken).toHaveBeenCalledWith(
|
||||||
|
'emp_1',
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(Date),
|
||||||
|
)
|
||||||
|
expect(sendTransactionalEmail).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: 'owner@company.com',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -78,7 +78,7 @@ export async function forgotPassword(email: string) {
|
|||||||
const lang = (employee.preferredLanguage as Lang) ?? 'fr'
|
const lang = (employee.preferredLanguage as Lang) ?? 'fr'
|
||||||
|
|
||||||
await sendTransactionalEmail({
|
await sendTransactionalEmail({
|
||||||
to: email,
|
to: employee.email,
|
||||||
subject: resetPasswordEmail.subject(lang),
|
subject: resetPasswordEmail.subject(lang),
|
||||||
html: resetPasswordEmail.html(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang),
|
html: resetPasswordEmail.html(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang),
|
||||||
text: resetPasswordEmail.text(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang),
|
text: resetPasswordEmail.text(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang),
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('../lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
employee: {
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
company: {
|
||||||
|
findUniqueOrThrow: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('./notificationService', () => ({
|
||||||
|
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import { prisma } from '../lib/prisma'
|
||||||
|
import { sendTransactionalEmail } from './notificationService'
|
||||||
|
import { inviteEmployee } from './teamService'
|
||||||
|
|
||||||
|
describe('teamService inviteEmployee', () => {
|
||||||
|
const originalDashboardUrl = process.env.DASHBOARD_URL
|
||||||
|
const originalPublicDashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
process.env.DASHBOARD_URL = 'http://localhost:3001'
|
||||||
|
process.env.NEXT_PUBLIC_DASHBOARD_URL = ''
|
||||||
|
|
||||||
|
vi.spyOn(crypto, 'randomBytes').mockReturnValue(Buffer.from('token-123'))
|
||||||
|
vi.spyOn(crypto, 'randomUUID').mockReturnValue('uuid-123')
|
||||||
|
|
||||||
|
vi.mocked(prisma.employee.findFirst).mockResolvedValue(null as any)
|
||||||
|
vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({ name: 'Atlas Cars' } as any)
|
||||||
|
vi.mocked(prisma.employee.create).mockResolvedValue({
|
||||||
|
id: 'emp_1',
|
||||||
|
firstName: 'Aya',
|
||||||
|
lastName: 'Benali',
|
||||||
|
email: 'aya@example.com',
|
||||||
|
role: 'AGENT',
|
||||||
|
isActive: true,
|
||||||
|
} as any)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
process.env.DASHBOARD_URL = originalDashboardUrl
|
||||||
|
process.env.NEXT_PUBLIC_DASHBOARD_URL = originalPublicDashboardUrl
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds invite reset links under the dashboard base path', async () => {
|
||||||
|
await inviteEmployee('company_1', 'owner_1', {
|
||||||
|
firstName: 'Aya',
|
||||||
|
lastName: 'Benali',
|
||||||
|
email: 'aya@example.com',
|
||||||
|
role: 'AGENT',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(sendTransactionalEmail).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: 'aya@example.com',
|
||||||
|
html: expect.stringContaining('http://localhost:3001/dashboard/reset-password?token=746f6b656e2d313233'),
|
||||||
|
text: expect.stringContaining('http://localhost:3001/dashboard/reset-password?token=746f6b656e2d313233'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -27,6 +27,18 @@ export interface TeamMember {
|
|||||||
invitationStatus?: 'accepted' | 'pending' | 'revoked'
|
invitationStatus?: 'accepted' | 'pending' | 'revoked'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureDashboardBasePath(baseUrl: string) {
|
||||||
|
try {
|
||||||
|
const url = new URL(baseUrl)
|
||||||
|
const pathname = url.pathname.replace(/\/$/, '')
|
||||||
|
if (!pathname.endsWith('/dashboard')) url.pathname = `${pathname}/dashboard`
|
||||||
|
return url.toString().replace(/\/$/, '')
|
||||||
|
} catch {
|
||||||
|
const trimmed = baseUrl.replace(/\/$/, '')
|
||||||
|
return trimmed.endsWith('/dashboard') ? trimmed : `${trimmed}/dashboard`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildInviteEmail(resetUrl: string, companyName: string, firstName: string, role: EmployeeRole) {
|
function buildInviteEmail(resetUrl: string, companyName: string, firstName: string, role: EmployeeRole) {
|
||||||
const greetingName = firstName.trim() || 'there'
|
const greetingName = firstName.trim() || 'there'
|
||||||
|
|
||||||
@@ -84,7 +96,9 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const dashboardUrl = process.env.DASHBOARD_URL ?? 'http://localhost:3001'
|
const dashboardUrl = ensureDashboardBasePath(
|
||||||
|
process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001/dashboard',
|
||||||
|
)
|
||||||
const resetUrl = `${dashboardUrl}/reset-password?token=${rawToken}`
|
const resetUrl = `${dashboardUrl}/reset-password?token=${rawToken}`
|
||||||
const message = buildInviteEmail(resetUrl, company.name, payload.firstName, payload.role)
|
const message = buildInviteEmail(resetUrl, company.name, payload.firstName, payload.role)
|
||||||
|
|
||||||
|
|||||||
@@ -164,8 +164,12 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
PGMANAGE_LISTEN_PORT: "8000"
|
PGMANAGE_LISTEN_PORT: "8000"
|
||||||
|
PGMANAGE_DEFAULT_USERNAME: ${PGMANAGE_DEFAULT_USERNAME:-admin}
|
||||||
|
PGMANAGE_DEFAULT_PASSWORD: ${PGMANAGE_DEFAULT_PASSWORD:-admin}
|
||||||
|
PGMANAGE_SECURE_COOKIES: "True"
|
||||||
volumes:
|
volumes:
|
||||||
- pgmanage_prod_data:/appdata
|
- pgmanage_prod_data:/appdata
|
||||||
|
- ./docker/pgmanage/override.py:/appdata/override.py:ro
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.http.routers.pgmanage.rule=Host(`${PGMANAGE_DOMAIN}`)
|
- traefik.http.routers.pgmanage.rule=Host(`${PGMANAGE_DOMAIN}`)
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
CSRF_TRUSTED_ORIGINS = ["https://pgmanage.rentaldrivego.ma"]
|
||||||
|
ALLOWED_HOSTS = ["pgmanage.rentaldrivego.ma"]
|
||||||
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
|
USE_X_FORWARDED_HOST = True
|
||||||
|
SESSION_COOKIE_SECURE = True
|
||||||
|
CSRF_COOKIE_SECURE = True
|
||||||
Reference in New Issue
Block a user