Compare commits

...

10 Commits

48 changed files with 1810 additions and 481 deletions

2
.gitignore vendored
View File

@ -9,7 +9,7 @@
!.yarn/plugins
!.yarn/releases
!.yarn/versions
.vscode
# testing
/coverage

View File

@ -7,6 +7,7 @@
"build": "next build",
"start": "next start --port 4000",
"lint": "next lint",
"test": "vitest",
"seed": "node -r dotenv/config ./src/bootstrap/db/seed.js"
},
"dependencies": {
@ -15,7 +16,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.454.0",
"next": "15.0.1",
"next": "15.0.2",
"postgres": "^3.4.5",
"react": "19.0.0-rc-69d4b800-20241021",
"react-dom": "19.0.0-rc-69d4b800-20241021",
@ -26,15 +27,22 @@
"zod": "^3.23.8"
},
"devDependencies": {
"@faker-js/faker": "^9.1.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.0.1",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@vitejs/plugin-react": "^4.3.3",
"bcrypt": "^5.1.1",
"dotenv": "^16.4.5",
"eslint": "^8",
"eslint-config-next": "15.0.1",
"jsdom": "^25.0.1",
"moq.ts": "^10.0.8",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
"typescript": "^5",
"vitest": "^2.1.4"
}
}

View File

@ -1,10 +1,10 @@
import fetchSummaryInfoUsecase from '@/feature/core/summary-info/domain/usecase/fetch-summary-info-usecase';
import {
BanknotesIcon,
ClockIcon,
UserGroupIcon,
InboxIcon,
} from '@heroicons/react/24/outline';
import { fetchCardData } from '@/app/lib/data';
const iconMap = {
collected: BanknotesIcon,
@ -14,21 +14,16 @@ const iconMap = {
};
export default async function CardWrapper() {
const {
numberOfInvoices,
numberOfCustomers,
totalPaidInvoices,
totalPendingInvoices,
} = await fetchCardData();
const {customersNumber, invoicesNumber, invoicesSummary } = await fetchSummaryInfoUsecase();
return (
<>
<Card title="Collected" value={totalPaidInvoices} type="collected" />
<Card title="Pending" value={totalPendingInvoices} type="pending" />
<Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
<Card title="Collected" value={invoicesSummary.paid} type="collected" />
<Card title="Pending" value={invoicesSummary.pending} type="pending" />
<Card title="Total Invoices" value={invoicesNumber} type="invoices" />
<Card
title="Total Customers"
value={numberOfCustomers}
value={customersNumber}
type="customers"
/>
</>

View File

@ -1,9 +1,9 @@
import fetchCustomerInvoicesUsecase from '@/feature/core/customer-invoice/domain/usecase/fetch-customer-invoices-usecase';
import { ArrowPathIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import Image from 'next/image';
import { fetchLatestInvoices } from '@/app/lib/data';
export default async function LatestInvoices() {
const latestInvoices = await fetchLatestInvoices();
const latestInvoices = await fetchCustomerInvoicesUsecase();
return (
<div className="flex w-full flex-col md:col-span-4">
@ -26,25 +26,25 @@ export default async function LatestInvoices() {
>
<div className="flex items-center">
<Image
src={invoice.image_url}
alt={`${invoice.name}'s profile picture`}
src={invoice.customerImageUrl}
alt={`${invoice.customerName}'s profile picture`}
className="mr-4 rounded-full"
width={32}
height={32}
/>
<div className="min-w-0">
<p className="truncate text-sm font-semibold md:text-base">
{invoice.name}
{invoice.customerName}
</p>
<p className="hidden text-sm text-gray-500 sm:block">
{invoice.email}
{invoice.customerEmail}
</p>
</div>
</div>
<p
className="truncate text-sm font-medium md:text-base"
>
{invoice.amount}
{invoice.invoicesAmount}
</p>
</div>
);

View File

@ -1,9 +1,9 @@
import { generateYAxis } from '@/app/lib/utils';
import fetchRevenuesUsecase from '@/feature/core/revenue/domain/usecase/fetch-revenues-usecase';
import { CalendarIcon } from '@heroicons/react/24/outline';
import { fetchRevenue } from '@/app/lib/data';
export default async function RevenueChart() {
const revenue = await fetchRevenue();
const revenue = await fetchRevenuesUsecase();
const chartHeight = 350;

View File

@ -0,0 +1,27 @@
import di from "@/bootstrap/di/init-di"
import fetchCustomerInvoicesUsecase from "@/feature/core/customer-invoice/domain/usecase/fetch-customer-invoices-usecase";
import fetchCustomersUsecase from "@/feature/core/customer/domain/usecase/fetch-customers-usecase";
import fetchAllInvoicesAmountUsecase from "@/feature/core/invoice/domain/usecase/fetch-all-invoices-amount-usecase";
import fetchRevenuesUsecase from "@/feature/core/revenue/domain/usecase/fetch-revenues-usecase";
export default function dashboardAppModule() {
const dashboardDi = di.createChildContainer()
dashboardDi.register(fetchCustomersUsecase.name, {
useValue: fetchCustomersUsecase
})
dashboardDi.register(fetchAllInvoicesAmountUsecase.name, {
useValue: fetchAllInvoicesAmountUsecase
})
dashboardDi.register(fetchAllInvoicesAmountUsecase.name, {
useValue: fetchAllInvoicesAmountUsecase
})
dashboardDi.register(fetchCustomerInvoicesUsecase.name, {
useValue: fetchCustomerInvoicesUsecase
})
dashboardDi.register(fetchRevenuesUsecase.name, {
useValue: fetchRevenuesUsecase
})
return dashboardDi
}

View File

@ -1,254 +0,0 @@
import { sql } from '@/bootstrap/db/db';
import {
CustomerField,
CustomersTableType,
InvoiceForm,
InvoicesTable,
User,
Revenue,
Invoice,
Customer,
LatestInvoice,
} from './definitions';
import { formatCurrency } from './utils';
import postgres from 'postgres';
import { connection } from 'next/server';
export async function fetchRevenue() {
// This is equivalent to in fetch(..., {cache: 'no-store'}).
connection()
try {
// Artificially delay a response for demo purposes.
// Don't do this in production :)
console.log('Fetching revenue data...');
await new Promise((resolve) => setTimeout(resolve, 3000));
const data = await sql`SELECT * FROM revenue`;
console.log('Data fetch completed after 3 seconds.');
return data as postgres.RowList<Revenue[]>;
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch revenue data.');
}
}
export async function fetchLatestInvoices() {
// This is equivalent to in fetch(..., {cache: 'no-store'}).
connection()
try {
const data = await sql`
SELECT invoices.amount, customers.name, customers.image_url, customers.email, invoices.id
FROM invoices
JOIN customers ON invoices.customer_id = customers.id
ORDER BY invoices.date DESC
LIMIT 5` as postgres.RowList<LatestInvoice[]>;
const latestInvoices = data.map((invoice) => ({
...invoice,
amount: formatCurrency(+invoice.amount),
}));
return latestInvoices;
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch the latest invoices.');
}
}
export async function fetchCardData() {
// This is equivalent to in fetch(..., {cache: 'no-store'}).
connection()
try {
// You can probably combine these into a single SQL query
// However, we are intentionally splitting them to demonstrate
// how to initialize multiple queries in parallel with JS.
const invoiceCountPromise = sql`SELECT COUNT(*) FROM invoices`;
const customerCountPromise = sql`SELECT COUNT(*) FROM customers`;
const invoiceStatusPromise = sql`SELECT
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS "paid",
SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS "pending"
FROM invoices`;
const data = await Promise.all([
invoiceCountPromise,
customerCountPromise,
invoiceStatusPromise,
]);
const invoices = data[0] as postgres.RowList<Invoice[]>
const customres = data[1] as postgres.RowList<Customer[]>
const invoiceStatus = data[2] as postgres.RowList<({paid: string, pending: string})[]>
const numberOfInvoices = Number(invoices.count ?? '0');
const numberOfCustomers = Number(customres.count ?? '0');
const totalPaidInvoices = formatCurrency(Number(invoiceStatus.at(0)?.paid ?? '0'));
const totalPendingInvoices = formatCurrency(Number(invoiceStatus.at(0)?.pending ?? '0'));
return {
numberOfCustomers,
numberOfInvoices,
totalPaidInvoices,
totalPendingInvoices,
};
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch card data.');
}
}
const ITEMS_PER_PAGE = 6;
export async function fetchFilteredInvoices(
query: string,
currentPage: number,
) {
// This is equivalent to in fetch(..., {cache: 'no-store'}).
connection()
const offset = (currentPage - 1) * ITEMS_PER_PAGE;
try {
const invoices = await sql`
SELECT
invoices.id,
invoices.amount,
invoices.date,
invoices.status,
customers.name,
customers.email,
customers.image_url
FROM invoices
JOIN customers ON invoices.customer_id = customers.id
WHERE
customers.name ILIKE ${`%${query}%`} OR
customers.email ILIKE ${`%${query}%`} OR
invoices.amount::text ILIKE ${`%${query}%`} OR
invoices.date::text ILIKE ${`%${query}%`} OR
invoices.status ILIKE ${`%${query}%`}
ORDER BY invoices.date DESC
LIMIT ${ITEMS_PER_PAGE} OFFSET ${offset}
` as postgres.RowList<InvoicesTable[]>;
return invoices;
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch invoices.');
}
}
export async function fetchInvoicesPages(query: string) {
// This is equivalent to in fetch(..., {cache: 'no-store'}).
connection()
try {
const count = await sql`SELECT COUNT(*)
FROM invoices
JOIN customers ON invoices.customer_id = customers.id
WHERE
customers.name ILIKE ${`%${query}%`} OR
customers.email ILIKE ${`%${query}%`} OR
invoices.amount::text ILIKE ${`%${query}%`} OR
invoices.date::text ILIKE ${`%${query}%`} OR
invoices.status ILIKE ${`%${query}%`}
`;
const totalPages = Math.ceil(Number(count.at(0)?.count) / ITEMS_PER_PAGE);
return totalPages;
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch total number of invoices.');
}
}
export async function fetchInvoiceById(id: string) {
// This is equivalent to in fetch(..., {cache: 'no-store'}).
connection()
try {
const data = await sql`
SELECT
invoices.id,
invoices.customer_id,
invoices.amount,
invoices.status
FROM invoices
WHERE invoices.id = ${id};
` as postgres.RowList<InvoiceForm[]>;
const invoice = data.map((invoice) => ({
...invoice,
// Convert amount from cents to dollars
amount: invoice.amount / 100,
}));
return invoice[0];
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch invoice.');
}
}
export async function fetchCustomers() {
// This is equivalent to in fetch(..., {cache: 'no-store'}).
connection()
try {
const data = await sql`
SELECT
id,
name
FROM customers
ORDER BY name ASC
` as postgres.RowList<CustomerField[]>;
return data;
} catch (err) {
console.error('Database Error:', err);
throw new Error('Failed to fetch all customers.');
}
}
export async function fetchFilteredCustomers(query: string) {
// This is equivalent to in fetch(..., {cache: 'no-store'}).
connection()
try {
const data = await sql`
SELECT
customers.id,
customers.name,
customers.email,
customers.image_url,
COUNT(invoices.id) AS total_invoices,
SUM(CASE WHEN invoices.status = 'pending' THEN invoices.amount ELSE 0 END) AS total_pending,
SUM(CASE WHEN invoices.status = 'paid' THEN invoices.amount ELSE 0 END) AS total_paid
FROM customers
LEFT JOIN invoices ON customers.id = invoices.customer_id
WHERE
customers.name ILIKE ${`%${query}%`} OR
customers.email ILIKE ${`%${query}%`}
GROUP BY customers.id, customers.name, customers.email, customers.image_url
ORDER BY customers.name ASC
` as postgres.RowList<CustomersTableType[]>;
const customers = data.map((customer) => ({
...customer,
total_pending: formatCurrency(customer.total_pending),
total_paid: formatCurrency(customer.total_paid),
}));
return customers;
} catch (err) {
console.error('Database Error:', err);
throw new Error('Failed to fetch customer table.');
}
}
export async function getUser(email: string) {
try {
const user = await sql`SELECT * FROM users WHERE email=${email}`;
return user.at(0) as User;
} catch (error) {
console.error('Failed to fetch user:', error);
throw new Error('Failed to fetch user.');
}
}

View File

@ -1,86 +0,0 @@
// This file contains type definitions for your data.
// It describes the shape of the data, and what data type each property should accept.
// For simplicity of teaching, we're manually defining these types.
// However, these types are generated automatically if you're using an ORM such as Prisma.
export type User = {
id: string;
name: string;
email: string;
password: string;
};
export type Customer = {
id: string;
name: string;
email: string;
image_url: string;
};
export type Invoice = {
id: string; // Will be created on the database
customer_id: string;
amount: number; // Stored in cents
status: 'pending' | 'paid';
date: string;
};
export type Revenue = {
month: string;
revenue: number;
};
export type LatestInvoice = {
id: string;
name: string;
image_url: string;
email: string;
amount: string;
};
// The database returns a number for amount, but we later format it to a string with the formatCurrency function
export type LatestInvoiceRaw = Omit<LatestInvoice, 'amount'> & {
amount: number;
};
export type InvoicesTable = {
id: string;
customer_id: string;
name: string;
email: string;
image_url: string;
date: string;
amount: number;
status: 'pending' | 'paid';
};
export type CustomersTableType = {
id: string;
name: string;
email: string;
image_url: string;
total_invoices: number;
total_pending: number;
total_paid: number;
};
export type FormattedCustomersTable = {
id: string;
name: string;
email: string;
image_url: string;
total_invoices: number;
total_pending: string;
total_paid: string;
};
export type CustomerField = {
id: string;
name: string;
};
export type InvoiceForm = {
id: string;
customer_id: string;
amount: number;
status: 'pending' | 'paid';
};

View File

@ -1,4 +1,4 @@
import { Revenue } from './definitions';
import Revenue from "@/feature/core/revenue/domain/entity/revenue";
export const formatCurrency = (amount: number) => {
return (amount / 100).toLocaleString('en-US', {
@ -7,20 +7,6 @@ export const formatCurrency = (amount: number) => {
});
};
export const formatDateToLocal = (
dateStr: string,
locale: string = 'en-US',
) => {
const date = new Date(dateStr);
const options: Intl.DateTimeFormatOptions = {
day: 'numeric',
month: 'short',
year: 'numeric',
};
const formatter = new Intl.DateTimeFormat(locale, options);
return formatter.format(date);
};
export const generateYAxis = (revenue: Revenue[]) => {
// Calculate what labels we need to display on the y-axis
// based on highest record and in 1000s
@ -34,36 +20,3 @@ export const generateYAxis = (revenue: Revenue[]) => {
return { yAxisLabels, topLabel };
};
export const generatePagination = (currentPage: number, totalPages: number) => {
// If the total number of pages is 7 or less,
// display all pages without any ellipsis.
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
// If the current page is among the first 3 pages,
// show the first 3, an ellipsis, and the last 2 pages.
if (currentPage <= 3) {
return [1, 2, 3, '...', totalPages - 1, totalPages];
}
// If the current page is among the last 3 pages,
// show the first 2, an ellipsis, and the last 3 pages.
if (currentPage >= totalPages - 2) {
return [1, 2, '...', totalPages - 2, totalPages - 1, totalPages];
}
// If the current page is somewhere in the middle,
// show the first page, an ellipsis, the current page and its neighbors,
// another ellipsis, and the last page.
return [
1,
'...',
currentPage - 1,
currentPage,
currentPage + 1,
'...',
totalPages,
];
};

View File

@ -1,13 +1,33 @@
import getCustomerInvoiceDi from "@/feature/core/customer-invoice/data/module/customer-invoice-di";
import { customerInvoiceModuleKey } from "@/feature/core/customer-invoice/invoice-module-key";
import { customerKey } from "@/feature/core/customer/customer-key";
import getCustomerDi from "@/feature/core/customer/data/module/customer-di";
import { testModuleKey } from "@/feature/domain/test/test-module-key";
import getTestModule from "@/feature/infra/test/module/test-module";
import getInvoiceDi from "@/feature/core/invoice/data/module/invoice-di";
import { invoiceModuleKey } from "@/feature/core/invoice/invoice-module-key";
import { DependencyContainer } from "tsyringe";
import { summaryInfoModuleKey } from "@/feature/core/summary-info/domain/summary-info-module-key";
import getSummaryInfoDi from "@/feature/core/summary-info/data/module/summary-info-di";
import { revenueModuleKey } from "@/feature/core/revenue/domain/revenue-module-key";
import getRevenueDi from "@/feature/core/revenue/data/module/revenue-di";
const memoizedDis: Record<string, DependencyContainer> = {}
export default function serverDi(module: string): DependencyContainer {
if (memoizedDis[module]) return memoizedDis[module]
const getDi = {
[testModuleKey]: getTestModule
[testModuleKey]: getTestModule,
[customerKey]: getCustomerDi,
[customerInvoiceModuleKey]: getCustomerInvoiceDi,
[invoiceModuleKey]: getInvoiceDi,
[summaryInfoModuleKey]: getSummaryInfoDi,
[revenueModuleKey]: getRevenueDi,
}[module]
if (!getDi) throw new Error("Server Di didn't found for module: " + module)
return getDi()
const di = getDi()
memoizedDis[module] = di
return di
}

View File

@ -0,0 +1,11 @@
import di from "@/bootstrap/di/init-di";
import CustomerInvoiceDbRepo from "@/feature/core/customer-invoice/data/repo/customer-invoice-db-repo";
import { customerInvoiceRepoKey } from "@/feature/core/customer-invoice/domain/i-repo/customer-invoice-repo";
import { DependencyContainer } from "tsyringe";
export default function getCustomerInvoiceDi(): DependencyContainer {
const customerInvoiceDi = di.createChildContainer()
customerInvoiceDi.register(customerInvoiceRepoKey, CustomerInvoiceDbRepo)
return customerInvoiceDi
}

View File

@ -0,0 +1,47 @@
import { formatCurrency } from "@/app/lib/utils";
import { sql } from "@/bootstrap/db/db";
import CustomerInvoice from "@/feature/core/customer-invoice/domain/entity/customer-invoice";
import CustomerInvoiceRepo from "@/feature/core/customer-invoice/domain/i-repo/customer-invoice-repo";
import postgres from "postgres";
type customerInvoiceDbResponse = {
id: string;
name: string;
image_url: string;
email: string;
amount: string;
}
export default class CustomerInvoiceDbRepo implements CustomerInvoiceRepo {
async fetchList(): Promise<CustomerInvoice[]> {
try {
const data = await sql`
SELECT invoices.amount, customers.name, customers.image_url, customers.email, invoices.id
FROM invoices
JOIN customers ON invoices.customer_id = customers.id
ORDER BY invoices.date DESC
LIMIT 5` as postgres.RowList<customerInvoiceDbResponse[]>;
return this.customerInvoicesDto(data)
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch the latest invoices.');
}
}
private customerInvoicesDto(dbCustomers: customerInvoiceDbResponse[]): CustomerInvoice[] {
return dbCustomers.map((customer) => this.customerInvoiceDto(customer));
}
private customerInvoiceDto(dbCustomer: customerInvoiceDbResponse): CustomerInvoice {
return new CustomerInvoice({
id: dbCustomer.id,
customerName: dbCustomer.name,
customerEmail: dbCustomer.email,
customerImageUrl: dbCustomer.image_url,
invoicesAmount: formatCurrency(+dbCustomer.amount),
})
}
}

View File

@ -0,0 +1,21 @@
export default class CustomerInvoice {
id: string;
customerName: string;
customerImageUrl: string;
customerEmail: string;
invoicesAmount: string;
constructor({
id,
customerEmail,
customerImageUrl,
customerName,
invoicesAmount
}: CustomerInvoice) {
this.id = id;
this.customerEmail = customerEmail
this.customerImageUrl = customerImageUrl
this.customerName = customerName
this.invoicesAmount = invoicesAmount
}
}

View File

@ -0,0 +1,7 @@
import CustomerInvoice from "@/feature/core/customer-invoice/domain/entity/customer-invoice"
export default interface CustomerInvoiceRepo {
fetchList(): Promise<CustomerInvoice[]>
}
export const customerInvoiceRepoKey = "customerInvoiceRepoKey"

View File

@ -0,0 +1,12 @@
"use server"
import serverDi from "@/feature/common/server-di";
import CustomerInvoice from "@/feature/core/customer-invoice/domain/entity/customer-invoice";
import CustomerInvoiceRepo, { customerInvoiceRepoKey } from "@/feature/core/customer-invoice/domain/i-repo/customer-invoice-repo";
import { customerInvoiceModuleKey } from "@/feature/core/customer-invoice/invoice-module-key";
import { connection } from "next/server";
export default async function fetchCustomerInvoicesUsecase(): Promise<CustomerInvoice[]> {
connection()
const repo = serverDi(customerInvoiceModuleKey).resolve<CustomerInvoiceRepo>(customerInvoiceRepoKey)
return repo.fetchList()
}

View File

@ -0,0 +1 @@
export const customerInvoiceModuleKey = "customerInvoiceModuleKey"

View File

@ -0,0 +1 @@
export const customerKey = "customerKey"

View File

@ -0,0 +1,11 @@
import di from "@/bootstrap/di/init-di";
import CustomerDbRepo from "@/feature/core/customer/data/repo/customer-db-repo";
import { customerRepoKey } from "@/feature/core/customer/domain/i-repo/customer-repo";
import { DependencyContainer } from "tsyringe";
export default function getCustomerDi(): DependencyContainer {
const customerDi = di.createChildContainer()
customerDi.register(customerRepoKey, CustomerDbRepo)
return customerDi
}

View File

@ -0,0 +1,71 @@
import { formatCurrency } from "@/app/lib/utils";
import { sql } from "@/bootstrap/db/db";
import Customer from "@/feature/core/customer/domain/entity/customer";
import CustomerRepo from "@/feature/core/customer/domain/i-repo/customer-repo";
import { connection } from "next/server";
import postgres from "postgres";
type customerDbResponse = {
id: string;
name: string;
email: string;
image_url: string;
total_invoices: string;
total_pending: string;
total_paid: string;
}
export default class CustomerDbRepo implements CustomerRepo {
async fetchList(query: string): Promise<Customer[]> {
// This is equivalent to in fetch(..., {cache: 'no-store'}).
connection()
try {
const data = await sql`
SELECT
customers.id,
customers.name,
customers.email,
customers.image_url,
COUNT(invoices.id) AS total_invoices,
SUM(CASE WHEN invoices.status = 'pending' THEN invoices.amount ELSE 0 END) AS total_pending,
SUM(CASE WHEN invoices.status = 'paid' THEN invoices.amount ELSE 0 END) AS total_paid
FROM customers
LEFT JOIN invoices ON customers.id = invoices.customer_id
WHERE
customers.name ILIKE ${`%${query}%`} OR
customers.email ILIKE ${`%${query}%`}
GROUP BY customers.id, customers.name, customers.email, customers.image_url
ORDER BY customers.name ASC
` as postgres.RowList<customerDbResponse[]>;
return this.customersDto(data);
} catch (err) {
console.error('Database Error:', err);
throw new Error('Failed to fetch customer table.');
}
}
async fetchCustomersAmount(): Promise<number> {
const data = await sql`SELECT COUNT(*) FROM customers`as postgres.RowList<unknown[]>;
return Number(data.count ?? '0');
}
private customersDto(dbCustomers: customerDbResponse[]): Customer[] {
return dbCustomers.map((customer) => this.customerDto(customer));
}
private customerDto(dbCustomer: customerDbResponse): Customer {
return new Customer({
id: dbCustomer.id,
name: dbCustomer.name,
email: dbCustomer.email,
imageUrl: dbCustomer.image_url,
totalInvoices: dbCustomer.total_invoices,
totalPending: formatCurrency(Number(dbCustomer.total_pending)),
totalPaid: formatCurrency(Number(dbCustomer.total_paid)),
})
}
}

View File

@ -0,0 +1,27 @@
export default class Customer {
id: string;
name: string;
email: string;
imageUrl: string;
totalInvoices: string;
totalPending: string;
totalPaid: string;
constructor({
id,
email,
imageUrl,
name,
totalInvoices,
totalPaid,
totalPending
}: Customer) {
this.id = id;
this.name = name;
this.email = email;
this.imageUrl = imageUrl;
this.totalInvoices = totalInvoices;
this.totalPaid = totalPaid;
this.totalPending = totalPending;
}
}

View File

@ -0,0 +1,8 @@
import Customer from "@/feature/core/customer/domain/entity/customer"
export default interface CustomerRepo {
fetchList(query: string): Promise<Customer[]>
fetchCustomersAmount(): Promise<number>
}
export const customerRepoKey = "customerRepoKey"

View File

@ -0,0 +1,8 @@
import serverDi from "@/feature/common/server-di";
import { customerKey } from "@/feature/core/customer/customer-key";
import CustomerRepo, { customerRepoKey } from "@/feature/core/customer/domain/i-repo/customer-repo";
export default async function fetchCustomersAmountUsecase(): Promise<number> {
const repo = serverDi(customerKey).resolve<CustomerRepo>(customerRepoKey)
return repo.fetchCustomersAmount()
}

View File

@ -0,0 +1,12 @@
"use server"
import serverDi from "@/feature/common/server-di";
import { customerKey } from "@/feature/core/customer/customer-key";
import Customer from "@/feature/core/customer/domain/entity/customer";
import CustomerRepo, { customerRepoKey } from "@/feature/core/customer/domain/i-repo/customer-repo";
export default async function fetchCustomersUsecase(query: string): Promise<Customer[]> {
const repo = serverDi(customerKey).resolve<CustomerRepo>(customerRepoKey)
return repo.fetchList(query)
}

View File

@ -0,0 +1,11 @@
import di from "@/bootstrap/di/init-di";
import invoiceDbRepo from "@/feature/core/invoice/data/repo/invoice-db-repo";
import { invoiceRepoKey } from "@/feature/core/invoice/domain/i-repo/invoice-repo";
import { DependencyContainer } from "tsyringe";
export default function getInvoiceDi(): DependencyContainer {
const invoiceDi = di.createChildContainer()
invoiceDi.register(invoiceRepoKey, invoiceDbRepo)
return invoiceDi
}

View File

@ -0,0 +1,31 @@
import { formatCurrency } from "@/app/lib/utils";
import { sql } from "@/bootstrap/db/db";
import InvoiceRepo from "@/feature/core/invoice/domain/i-repo/invoice-repo";
import InvoiceStatusSummary from "@/feature/core/invoice/domain/value-object/invoice-status";
import postgres from "postgres";
type InvoiceSummaryDbResponse = {paid: string, pending: string}
export default class InvoiceDbRepo implements InvoiceRepo {
async fetchAllInvoicesAmount(): Promise<number> {
const data = await sql`SELECT COUNT(*) FROM invoices` as postgres.RowList<unknown[]>;
return data.count ?? 0
}
async fetchInvoicesStatusSummary(): Promise<InvoiceStatusSummary> {
const invoiceStatusPromise = await sql`SELECT
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS "paid",
SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS "pending"
FROM invoices` as postgres.RowList<InvoiceSummaryDbResponse[]>;
return this.invoiceSummaryDto(invoiceStatusPromise.at(0))
}
private invoiceSummaryDto(dbResponse?: InvoiceSummaryDbResponse): InvoiceStatusSummary {
return new InvoiceStatusSummary({
paid: formatCurrency(Number(dbResponse?.paid ?? '0')),
pending: formatCurrency(Number(dbResponse?.pending ?? '0'))
})
}
}

View File

@ -0,0 +1,8 @@
import InvoiceStatusSummary from "@/feature/core/invoice/domain/value-object/invoice-status"
export default interface InvoiceRepo {
fetchAllInvoicesAmount(): Promise<number>
fetchInvoicesStatusSummary(): Promise<InvoiceStatusSummary>
}
export const invoiceRepoKey = "invoiceRepoKey"

View File

@ -0,0 +1,10 @@
"use server"
import serverDi from "@/feature/common/server-di";
import InvoiceRepo, { invoiceRepoKey } from "@/feature/core/invoice/domain/i-repo/invoice-repo";
import { invoiceModuleKey } from "@/feature/core/invoice/invoice-module-key";
export default async function fetchAllInvoicesAmountUsecase(): Promise<number> {
const repo = serverDi(invoiceModuleKey).resolve<InvoiceRepo>(invoiceRepoKey)
return repo.fetchAllInvoicesAmount()
}

View File

@ -0,0 +1,9 @@
import serverDi from "@/feature/common/server-di";
import InvoiceRepo, { invoiceRepoKey } from "@/feature/core/invoice/domain/i-repo/invoice-repo";
import InvoiceStatusSummary from "@/feature/core/invoice/domain/value-object/invoice-status";
import { invoiceModuleKey } from "@/feature/core/invoice/invoice-module-key";
export default async function fetchInvoicesStatusSummary(): Promise<InvoiceStatusSummary> {
const repo = serverDi(invoiceModuleKey).resolve<InvoiceRepo>(invoiceRepoKey)
return repo.fetchInvoicesStatusSummary()
}

View File

@ -0,0 +1,12 @@
export default class InvoiceStatusSummary {
paid: string;
pending: string;
constructor({
paid,
pending
}: InvoiceStatusSummary) {
this.paid = paid;
this.pending = pending;
}
}

View File

@ -0,0 +1 @@
export const invoiceModuleKey = "invoiceModuleKey"

View File

@ -0,0 +1,10 @@
import di from "@/bootstrap/di/init-di"
import RevenueDbRepo from "@/feature/core/revenue/data/repo/revenue-db-repo"
import { revenueRepoKey } from "@/feature/core/revenue/domain/i-repo/revenue-repo"
export default function getRevenueDi() {
const revenueDi = di.createChildContainer()
revenueDi.register(revenueRepoKey, RevenueDbRepo)
return revenueDi
}

View File

@ -0,0 +1,42 @@
import { sql } from "@/bootstrap/db/db";
import Revenue from "@/feature/core/revenue/domain/entity/revenue";
import RevenueRepo from "@/feature/core/revenue/domain/i-repo/revenue-repo";
import { connection } from "next/server";
import postgres from "postgres";
export type RevenueDbResponse = {
month: string;
revenue: number;
};
export default class RevenueDbRepo implements RevenueRepo {
async fetchRevenues(): Promise<Revenue[]> {
// This is equivalent to in fetch(..., {cache: 'no-store'}).
try {
// Artificially delay a response for demo purposes.
// Don't do this in production :)
await new Promise((resolve) => setTimeout(resolve, 3000));
const data = await sql`SELECT * FROM revenue` as postgres.RowList<RevenueDbResponse[]>;
console.log('Data fetch completed after 3 seconds.');
return this.revenuesDto(data);
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch revenue data.');
}
}
private revenuesDto(dbResponse: RevenueDbResponse[]): Revenue[] {
return dbResponse.map((dbRevenue) => this.revenueDto(dbRevenue))
}
private revenueDto(dbResponse: RevenueDbResponse): Revenue {
return new Revenue({
month: dbResponse.month,
revenue: dbResponse.revenue
})
}
}

View File

@ -0,0 +1,14 @@
export default class Revenue {
month: string;
revenue: number;
constructor(
{
month,
revenue
}: Revenue
) {
this.month = month
this.revenue = revenue
}
}

View File

@ -0,0 +1,7 @@
import Revenue from "@/feature/core/revenue/domain/entity/revenue";
export default interface RevenueRepo {
fetchRevenues(): Promise<Revenue[]>
}
export const revenueRepoKey = "revenueRepoKey"

View File

@ -0,0 +1 @@
export const revenueModuleKey = "RevenueModuleKey"

View File

@ -0,0 +1,9 @@
import serverDi from "@/feature/common/server-di";
import Revenue from "@/feature/core/revenue/domain/entity/revenue";
import RevenueRepo, { revenueRepoKey } from "@/feature/core/revenue/domain/i-repo/revenue-repo";
import { revenueModuleKey } from "@/feature/core/revenue/domain/revenue-module-key";
export default async function fetchRevenuesUsecase(): Promise<Revenue[]> {
const repo = serverDi(revenueModuleKey).resolve<RevenueRepo>(revenueRepoKey)
return repo.fetchRevenues()
}

View File

@ -0,0 +1,19 @@
import di from "@/bootstrap/di/init-di"
import fetchCustomersAmountUsecase from "@/feature/core/customer/domain/usecase/fetch-customers-amount-usecase"
import fetchAllInvoicesAmountUsecase from "@/feature/core/invoice/domain/usecase/fetch-all-invoices-amount-usecase"
import fetchInvoicesStatusSummary from "@/feature/core/invoice/domain/usecase/fetch-invoices-status-summary"
export default function getSummaryInfoDi() {
const summaryInfoDi = di.createChildContainer()
summaryInfoDi.register(fetchAllInvoicesAmountUsecase.name, {
useValue: fetchAllInvoicesAmountUsecase
})
summaryInfoDi.register(fetchCustomersAmountUsecase.name, {
useValue: fetchCustomersAmountUsecase
})
summaryInfoDi.register(fetchInvoicesStatusSummary.name, {
useValue: fetchInvoicesStatusSummary
})
return summaryInfoDi
}

View File

@ -0,0 +1 @@
export const summaryInfoModuleKey = "summaryInfoModuleKey"

View File

@ -0,0 +1,34 @@
import serverDi from "@/feature/common/server-di";
import fetchCustomersAmountUsecase from "@/feature/core/customer/domain/usecase/fetch-customers-amount-usecase";
import fetchAllInvoicesAmountUsecase from "@/feature/core/invoice/domain/usecase/fetch-all-invoices-amount-usecase";
import fetchInvoicesStatusSummary from "@/feature/core/invoice/domain/usecase/fetch-invoices-status-summary";
import { summaryInfoModuleKey } from "@/feature/core/summary-info/domain/summary-info-module-key";
import SummaryInfo from "@/feature/core/summary-info/domain/value-object/summary-info";
import { connection } from "next/server";
export default async function fetchSummaryInfoUsecase(): Promise<SummaryInfo> {
connection()
try{
const summaryInfoDi = serverDi(summaryInfoModuleKey)
const invoicesAmountPromise = summaryInfoDi.resolve<typeof fetchAllInvoicesAmountUsecase>(fetchAllInvoicesAmountUsecase.name)()
const customersAmountPromise = summaryInfoDi.resolve<typeof fetchCustomersAmountUsecase>(fetchCustomersAmountUsecase.name)()
const invoiceSummaryPomise = summaryInfoDi.resolve<typeof fetchInvoicesStatusSummary>(fetchInvoicesStatusSummary.name)()
const [invoicesAmount, customersAmount, invoicesSummary] = await Promise.all([
invoicesAmountPromise,
customersAmountPromise,
invoiceSummaryPomise,
]);
return new SummaryInfo({
invoicesNumber: invoicesAmount,
customersNumber: customersAmount,
invoicesSummary: invoicesSummary
})
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch card data.');
}
}

View File

@ -0,0 +1,17 @@
import InvoiceStatusSummary from "@/feature/core/invoice/domain/value-object/invoice-status";
export default class SummaryInfo {
customersNumber: number;
invoicesNumber: number;
invoicesSummary: InvoiceStatusSummary
constructor({
customersNumber,
invoicesNumber,
invoicesSummary
}: SummaryInfo) {
this.customersNumber = customersNumber
this.invoicesNumber = invoicesNumber
this.invoicesSummary = invoicesSummary
}
}

View File

@ -0,0 +1,21 @@
import Customer from "@/feature/core/customer/domain/entity/customer";
import { faker } from "@faker-js/faker";
export default class CustomerFakeFactory {
static getFakeCustomer(): Customer {
return new Customer({
id: faker.string.uuid(),
name: faker.person.fullName(),
email: faker.internet.email(),
imageUrl: faker.image.url(),
totalInvoices: faker.number.int().toLocaleString(),
totalPaid: faker.finance.amount(),
totalPending: faker.number.int().toLocaleString(),
})
}
static getFakeCustomerList(length: number = 10): Customer[] {
return Array.from({length}).map(() => CustomerFakeFactory.getFakeCustomer())
}
}

View File

@ -0,0 +1,7 @@
import di from "@/bootstrap/di/init-di"
import * as serverDi from "@/feature/common/server-di"
export default function mockDi() {
vi.spyOn(serverDi, "default").mockReturnValue(di)
return di
}

View File

@ -0,0 +1,5 @@
import { Mock } from "moq.ts";
export function getMock<T>() {
return new Mock<T>();
}

1
src/test/setup.ts Normal file
View File

@ -0,0 +1 @@
import "reflect-metadata";

View File

@ -0,0 +1,48 @@
import CustomerRepo, { customerRepoKey } from "@/feature/core/customer/domain/i-repo/customer-repo";
import { getMock } from "@/test/common/mock/mock-factory";
import { describe } from "vitest";
import { faker } from "@faker-js/faker";
import CustomerFakeFactory from "@/test/common/fake-factory/customer/customer-fake-factory";
import fetchCustomersUsecase from "@/feature/core/customer/domain/usecase/fetch-customers-usecase";
import mockDi from "@/test/common/mock/mock-di";
/* -------------------------------------------------------------------------- */
/* Faking */
/* -------------------------------------------------------------------------- */
const fakedCustomerList = CustomerFakeFactory.getFakeCustomerList()
/* -------------------------------------------------------------------------- */
/* Mocking */
/* -------------------------------------------------------------------------- */
const customerDi = mockDi()
const mockedFetchList = vi.fn<CustomerRepo['fetchList']>()
const MockedRepo = getMock<CustomerRepo>()
MockedRepo.setup((instance) => instance.fetchList).returns(mockedFetchList)
/* -------------------------------------------------------------------------- */
/* DI */
/* -------------------------------------------------------------------------- */
customerDi.register(fetchCustomersUsecase.name, {
useValue: fetchCustomersUsecase
})
customerDi.register(customerRepoKey, {
useValue: MockedRepo.object()
})
/* -------------------------------------------------------------------------- */
/* Testing */
/* -------------------------------------------------------------------------- */
const usecase = customerDi.resolve<typeof fetchCustomersUsecase>(fetchCustomersUsecase.name)
describe("Fetch customers", () => {
describe("On given query string", () => {
const fakedQuery = faker.person.fullName();
describe("And returning list from repo", () => {
beforeEach(() => {
mockedFetchList.mockResolvedValue(fakedCustomerList)
})
it("Then should return correct list of customers", async () => {
// ! Act
const response = await usecase(fakedQuery)
// ? Assert
expect(response).toEqual(fakedCustomerList)
})
});
});
});

View File

@ -15,6 +15,7 @@
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"types": ["vitest/globals"],
"plugins": [
{
"name": "next"

15
vite.config.ts Normal file
View File

@ -0,0 +1,15 @@
/// <reference types="vitest" />
import { defineConfig } from "vitest/config";
import path from 'path';
export default defineConfig({
test: {
globals: true,
setupFiles: 'src/test/setup.ts',
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})

1229
yarn.lock

File diff suppressed because it is too large Load Diff