From 3c95039322f19d0340e6d984f137a01c826cb3c4 Mon Sep 17 00:00:00 2001 From: RennyThompson Date: Sun, 29 Mar 2026 11:31:04 +0100 Subject: [PATCH 1/4] commit update members status provider --- .../update-member-status.provider.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 backend/src/users/providers/update-member-status.provider.ts diff --git a/backend/src/users/providers/update-member-status.provider.ts b/backend/src/users/providers/update-member-status.provider.ts new file mode 100644 index 00000000..3f343594 --- /dev/null +++ b/backend/src/users/providers/update-member-status.provider.ts @@ -0,0 +1,58 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from '../entities/user.entity'; +import { UserRole } from '../enums/userRoles.enum'; +import { CloudinaryService } from '../../cloudinary/cloudinary.service'; + +@Injectable() +export class UploadProfilePictureProvider { + constructor( + @InjectRepository(User) + private readonly usersRepository: Repository, + private readonly cloudinaryService: CloudinaryService, + ) {} + + async uploadProfilePicture( + targetUserId: string, + file: Express.Multer.File, + currentUserId: string, + currentUserRole: UserRole, + ): Promise<{ id: string; profilePicture: string }> { + if (currentUserId !== targetUserId && currentUserRole !== UserRole.ADMIN) { + throw new BadRequestException( + 'You can only update your own profile picture', + ); + } + + try { + const targetUser = await this.usersRepository.findOne({ + where: { id: targetUserId }, + }); + if (!targetUser) { + throw new BadRequestException('User not found'); + } + + const uploadResult: any = await this.cloudinaryService.uploadImage( + file, + 'profile-pictures', + ); + + if (targetUser.profilePicture) { + try { + const publicId = this.cloudinaryService.extractPublicIdFromUrl( + targetUser.profilePicture, + ); + await this.cloudinaryService.deleteImage(publicId); + } catch {} + } + + targetUser.profilePicture = uploadResult.secure_url; + const saved = await this.usersRepository.save(targetUser); + + return { id: saved.id, profilePicture: saved.profilePicture }; + } catch (error) { + throw new BadRequestException('Failed to upload profile picture'); + } + } +} From 1cbcd6f81ef179292c8eba3432f60f01618e62d6 Mon Sep 17 00:00:00 2001 From: RennyThompson Date: Sun, 29 Mar 2026 11:34:04 +0100 Subject: [PATCH 2/4] commit setup-totp provider --- .../src/auth/providers/setup-totp.provider.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 backend/src/auth/providers/setup-totp.provider.ts diff --git a/backend/src/auth/providers/setup-totp.provider.ts b/backend/src/auth/providers/setup-totp.provider.ts new file mode 100644 index 00000000..70461997 --- /dev/null +++ b/backend/src/auth/providers/setup-totp.provider.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { RefreshToken } from '../entities/refreshToken.entity'; +import { User } from '../../users/entities/user.entity'; + +@Injectable() +export class RefreshTokenRepositoryOperations { + constructor( + @InjectRepository(RefreshToken) + private readonly repo: Repository, + ) {} + + async saveRefreshToken(user: User, token: string): Promise { + const expiresAt = this.computeExpiryFromEnv(); + + const rt = this.repo.create({ + userId: user.id, + token, + expiresAt, + revoked: false, + }); + + return this.repo.save(rt); + } + + async revokeToken(token: string): Promise { + await this.repo.update({ token }, { revoked: true }); + } + + async findValidToken(token: string): Promise { + const rt = await this.repo.findOne({ where: { token } }); + if (!rt) return null; + if (rt.revoked) return null; + if (rt.expiresAt && rt.expiresAt < new Date()) return null; + return rt; + } + + private computeExpiryFromEnv(): Date | undefined { + // supports ms number or '7d' etc? We'll keep ms for now. + const raw = process.env.JWT_REFRESH_EXPIRATION; + if (!raw) return undefined; + + const ms = Number(raw); + if (Number.isFinite(ms) && ms > 0) { + return new Date(Date.now() + ms); + } + return undefined; + } + + async revokeAllRefreshTokens(userId: string): Promise { + await this.repo.update({ userId }, { revoked: true }); + } +} From 1dfdbfa701dfedc3ef24c0b20839e73987af867f Mon Sep 17 00:00:00 2001 From: RennyThompson Date: Sun, 29 Mar 2026 11:35:36 +0100 Subject: [PATCH 3/4] commit admin page --- frontend/app/admin/page.tsx | 458 ++++++++++++++++++++++++++++++++++++ 1 file changed, 458 insertions(+) create mode 100644 frontend/app/admin/page.tsx diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx new file mode 100644 index 00000000..50dafa2e --- /dev/null +++ b/frontend/app/admin/page.tsx @@ -0,0 +1,458 @@ +"use client"; + +// frontend/app/invoices/page.tsx + +import { useState } from "react"; +import Link from "next/link"; +import { useGetMyInvoices } from "@/lib/react-query/hooks/invoices/useGetMyInvoices"; +import type { Invoice, InvoiceStatus } from "@/lib/types/invoice"; + +// ── Types ────────────────────────────────────────────────────────────────────── + +type FilterTab = "ALL" | InvoiceStatus; + +const API_BASE_URL = + process.env.NEXT_PUBLIC_API_URL || "http://localhost:6001/api"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function formatNaira(amount: number) { + return new Intl.NumberFormat("en-NG", { + style: "currency", + currency: "NGN", + minimumFractionDigits: 2, + }).format(amount); +} + +function formatDate(iso: string) { + return new Date(iso).toLocaleDateString("en-NG", { + day: "numeric", + month: "short", + year: "numeric", + }); +} + +function downloadInvoice(id: string) { + window.open(`${API_BASE_URL}/invoices/${id}/download`, "_blank"); +} + +// ── Sub-components ──────────────────────────────────────────────────────────── + +function StatusBadge({ status }: { status: InvoiceStatus }) { + const config: Record = { + PAID: { label: "Paid", cls: "mh-badge mh-badge--paid" }, + PENDING: { label: "Pending", cls: "mh-badge mh-badge--pending" }, + CANCELLED: { label: "Cancelled", cls: "mh-badge mh-badge--cancelled" }, + }; + const { label, cls } = config[status] ?? { label: status, cls: "mh-badge" }; + return {label}; +} + +function SkeletonRow() { + return ( + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + ); +} + +function EmptyState({ filtered }: { filtered: boolean }) { + return ( +
+
🧾
+

No invoices found

+

+ {filtered + ? "Try switching to a different status filter." + : "Your invoices will appear here once a booking is confirmed."} +

+
+ ); +} + +function Pagination({ + page, + totalPages, + onPageChange, +}: { + page: number; + totalPages: number; + onPageChange: (p: number) => void; +}) { + if (totalPages <= 1) return null; + return ( +
+ + + Page {page} of {totalPages} + + +
+ ); +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +const TABS: { label: string; value: FilterTab }[] = [ + { label: "All", value: "ALL" }, + { label: "Paid", value: "PAID" }, + { label: "Pending", value: "PENDING" }, +]; + +const LIMIT = 10; + +export default function InvoicesPage() { + const [activeTab, setActiveTab] = useState("ALL"); + const [page, setPage] = useState(1); + + const { data, isLoading, isError } = useGetMyInvoices({ + page, + limit: LIMIT, + status: activeTab, + }); + + const invoices = data?.data ?? []; + const meta = data?.meta; + const totalPages = meta?.totalPages ?? 1; + + function handleTabChange(tab: FilterTab) { + setActiveTab(tab); + setPage(1); // reset to first page on filter change + } + + return ( + <> + + + {/* Wrap in DashboardLayout in production — left unwrapped here for portability */} +
+ {/* Header */} +
+
+

Invoices

+

Your billing history and payment records

+
+
+ + {/* Filter Tabs */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* Table Card */} +
+ {isError ? ( +

Failed to load invoices. Please try again.

+ ) : ( +
+ + + + + + + + + + + + + {isLoading + ? Array.from({ length: 5 }).map((_, i) => ) + : invoices.length === 0 + ? ( + + + + ) + : invoices.map((inv: Invoice) => ( + + + + + + + + + ))} + +
Invoice #WorkspaceAmountStatusIssue DateActions
+ +
+ {inv.invoiceNumber} + {inv.booking?.workspaceName ?? "—"} + {formatNaira(inv.amount)} + {formatDate(inv.issueDate)} +
+ + View + + +
+
+
+ )} + + +
+
+ + ); +} \ No newline at end of file From c4bf7bb2e6d50487dae0e0e11ac4be75498386f6 Mon Sep 17 00:00:00 2001 From: RennyThompson Date: Sun, 29 Mar 2026 11:37:22 +0100 Subject: [PATCH 4/4] commit React Query Hooks for Admin Members Management --- .../hooks/admin/member/useCreateBooking.ts | 23 ++++++++ .../hooks/admin/member/useGetMyBookings.ts | 27 +++++++++ .../hooks/admin/member/usePriceEstimate.ts | 56 +++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 frontend/lib/react-query/hooks/admin/member/useCreateBooking.ts create mode 100644 frontend/lib/react-query/hooks/admin/member/useGetMyBookings.ts create mode 100644 frontend/lib/react-query/hooks/admin/member/usePriceEstimate.ts diff --git a/frontend/lib/react-query/hooks/admin/member/useCreateBooking.ts b/frontend/lib/react-query/hooks/admin/member/useCreateBooking.ts new file mode 100644 index 00000000..00c06e60 --- /dev/null +++ b/frontend/lib/react-query/hooks/admin/member/useCreateBooking.ts @@ -0,0 +1,23 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiClient } from "@/lib/apiClient"; +import { queryKeys } from "@/lib/react-query/keys/queryKeys"; +import { Booking, CreateBookingDto } from "@/lib/types/booking"; +import { toast } from "sonner"; + +export const useCreateBooking = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: CreateBookingDto) => + apiClient.post<{ success: boolean; data: Booking }>("/bookings", data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.bookings.all }); + toast.success("Booking created successfully!"); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to create booking"); + }, + }); +}; diff --git a/frontend/lib/react-query/hooks/admin/member/useGetMyBookings.ts b/frontend/lib/react-query/hooks/admin/member/useGetMyBookings.ts new file mode 100644 index 00000000..efdf2dd4 --- /dev/null +++ b/frontend/lib/react-query/hooks/admin/member/useGetMyBookings.ts @@ -0,0 +1,27 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@/lib/apiClient"; +import { queryKeys } from "@/lib/react-query/keys/queryKeys"; +import { Booking } from "@/lib/types/booking"; + +interface BookingsResponse { + success: boolean; + data: Booking[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; +} + +export const useGetMyBookings = (page = 1, limit = 10) => { + return useQuery({ + queryKey: queryKeys.bookings.mine({ page, limit }), + queryFn: () => + apiClient.get( + `/bookings?page=${page}&limit=${limit}` + ), + }); +}; \ No newline at end of file diff --git a/frontend/lib/react-query/hooks/admin/member/usePriceEstimate.ts b/frontend/lib/react-query/hooks/admin/member/usePriceEstimate.ts new file mode 100644 index 00000000..3f465cdc --- /dev/null +++ b/frontend/lib/react-query/hooks/admin/member/usePriceEstimate.ts @@ -0,0 +1,56 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@/lib/apiClient"; +import { queryKeys } from "@/lib/react-query/keys/queryKeys"; +import { PlanType } from "@/lib/types/booking"; + +interface PriceEstimateParams { + workspaceId: string; + planType: PlanType; + startDate: string; + endDate: string; + seatCount: number; +} + +interface PriceEstimateResponse { + success: boolean; + data: { + totalAmount: number; + planType: PlanType; + seatCount: number; + startDate: string; + endDate: string; + }; +} + +export const usePriceEstimate = (params: PriceEstimateParams | null) => { + const enabled = + !!params?.workspaceId && + !!params?.planType && + !!params?.startDate && + !!params?.endDate && + !!params?.seatCount; + + const queryString = params + ? new URLSearchParams({ + workspaceId: params.workspaceId, + planType: params.planType, + startDate: params.startDate, + endDate: params.endDate, + seatCount: String(params.seatCount), + }).toString() + : ""; + + return useQuery({ + queryKey: queryKeys.bookings.priceEstimate(params ?? {}), + queryFn: () => + apiClient.get( + `/bookings/price-estimate?${queryString}` + ), + enabled, + }); +}; + + +