لغة TypeScript
بناء مشروع TypeScript متكامل - الجزء 1
بناء مشروع TypeScript متكامل - الجزء 1
في هذا الدرس من جزأين، سنبني تطبيق TypeScript متكامل: نظام إدارة مهام مع مصادقة، تحديثات في الوقت الفعلي، وواجهة أمامية حديثة. الجزء 1 يغطي إعداد المشروع، الأنواع المشتركة، طبقة API، وبنية الواجهة الأمامية.
نظرة عامة على المشروع
سنبني نظام إدارة مهام مع:
- الخلفية: Node.js + Express + TypeScript + Prisma
- الواجهة الأمامية: React + TypeScript + Vite
- قاعدة البيانات: PostgreSQL
- الوقت الفعلي: دعم WebSocket
- المصادقة: مصادقة قائمة على JWT
هيكل المشروع
task-manager/
├── packages/
│ ├── shared/ # أنواع وأدوات مشتركة
│ │ ├── src/
│ │ │ ├── types/
│ │ │ │ ├── user.ts
│ │ │ │ ├── task.ts
│ │ │ │ └── api.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── backend/ # Express API
│ │ ├── src/
│ │ │ ├── routes/
│ │ │ ├── controllers/
│ │ │ ├── services/
│ │ │ ├── middleware/
│ │ │ └── server.ts
│ │ ├── prisma/
│ │ ├── package.json
│ │ └── tsconfig.json
│ └── frontend/ # تطبيق React
│ ├── src/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── services/
│ │ └── App.tsx
│ ├── package.json
│ └── tsconfig.json
├── package.json # حزمة الجذر (مساحة العمل)
└── tsconfig.json # التكوين الأساسي
الخطوة 1: تهيئة Monorepo
# إنشاء المشروع
mkdir task-manager && cd task-manager
# تهيئة حزمة الجذر
npm init -y
# تكوين كمساحة عمل
# تحرير package.json:
{
"name": "task-manager",
"private": true,
"workspaces": [
"packages/*"
]
}
الخطوة 2: حزمة الأنواع المشتركة
# إنشاء حزمة مشتركة
mkdir -p packages/shared/src/types
cd packages/shared
npm init -y
npm install typescript --save-dev
// packages/shared/tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
// packages/shared/src/types/user.ts
export interface User {
id: string;
email: string;
name: string;
createdAt: Date;
updatedAt: Date;
}
export interface UserCreate {
email: string;
name: string;
password: string;
}
export interface UserLogin {
email: string;
password: string;
}
export interface UserUpdate {
name?: string;
email?: string;
}
export type UserPublic = Omit<User, 'password'>;
// packages/shared/src/types/task.ts
export type TaskStatus = 'pending' | 'in-progress' | 'completed' | 'cancelled';
export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
export interface Task {
id: string;
title: string;
description: string | null;
status: TaskStatus;
priority: TaskPriority;
dueDate: Date | null;
userId: string;
createdAt: Date;
updatedAt: Date;
}
export interface TaskCreate {
title: string;
description?: string;
status?: TaskStatus;
priority?: TaskPriority;
dueDate?: Date;
}
export interface TaskUpdate {
title?: string;
description?: string | null;
status?: TaskStatus;
priority?: TaskPriority;
dueDate?: Date | null;
}
export interface TaskFilter {
status?: TaskStatus;
priority?: TaskPriority;
search?: string;
userId?: string;
}
// packages/shared/src/types/api.ts
// أنواع استجابة API عامة
export interface ApiResponse<T> {
data: T;
message?: string;
}
export interface ApiError {
error: string;
details?: unknown;
statusCode: number;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface PaginationParams {
page?: number;
pageSize?: number;
}
// أنواع المصادقة
export interface AuthTokens {
accessToken: string;
refreshToken: string;
}
export interface AuthResponse {
user: UserPublic;
tokens: AuthTokens;
}
// أنواع رسائل WebSocket
export type WsEventType = 'task:created' | 'task:updated' | 'task:deleted';
export interface WsMessage<T = unknown> {
type: WsEventType;
payload: T;
timestamp: number;
}
// packages/shared/src/index.ts
export * from './types/user';
export * from './types/task';
export * from './types/api';
// packages/shared/package.json
{
"name": "@task-manager/shared",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"watch": "tsc --watch"
},
"devDependencies": {
"typescript": "^5.0.0"
}
}
الخطوة 3: إعداد الخلفية
# إنشاء حزمة الخلفية
mkdir -p packages/backend/src
cd packages/backend
npm init -y
# تثبيت التبعيات
npm install express cors dotenv bcrypt jsonwebtoken
npm install @task-manager/shared
# تثبيت تبعيات التطوير
npm install --save-dev typescript @types/node @types/express \
@types/cors @types/bcrypt @types/jsonwebtoken \
ts-node nodemon prisma @prisma/client
// packages/backend/tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "commonjs",
"target": "ES2020"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
// packages/backend/prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
email String @unique
name String
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tasks Task[]
}
model Task {
id String @id @default(uuid())
title String
description String?
status String @default("pending")
priority String @default("medium")
dueDate DateTime?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([status])
}
الخطوة 4: خدمات الخلفية
// packages/backend/src/services/auth.service.ts
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { PrismaClient } from '@prisma/client';
import type { UserCreate, UserLogin, AuthTokens, UserPublic } from '@task-manager/shared';
const prisma = new PrismaClient();
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'your-refresh-secret';
export class AuthService {
async register(data: UserCreate): Promise<UserPublic> {
const existingUser = await prisma.user.findUnique({
where: { email: data.email }
});
if (existingUser) {
throw new Error('Email already registered');
}
const hashedPassword = await bcrypt.hash(data.password, 10);
const user = await prisma.user.create({
data: {
email: data.email,
name: data.name,
password: hashedPassword
}
});
return this.toPublicUser(user);
}
async login(data: UserLogin): Promise<{ user: UserPublic; tokens: AuthTokens }> {
const user = await prisma.user.findUnique({
where: { email: data.email }
});
if (!user) {
throw new Error('Invalid credentials');
}
const validPassword = await bcrypt.compare(data.password, user.password);
if (!validPassword) {
throw new Error('Invalid credentials');
}
const tokens = this.generateTokens(user.id);
return {
user: this.toPublicUser(user),
tokens
};
}
generateTokens(userId: string): AuthTokens {
const accessToken = jwt.sign({ userId }, JWT_SECRET, { expiresIn: '15m' });
const refreshToken = jwt.sign({ userId }, JWT_REFRESH_SECRET, { expiresIn: '7d' });
return { accessToken, refreshToken };
}
verifyAccessToken(token: string): { userId: string } {
try {
return jwt.verify(token, JWT_SECRET) as { userId: string };
} catch {
throw new Error('Invalid token');
}
}
private toPublicUser(user: any): UserPublic {
const { password, ...publicUser } = user;
return publicUser;
}
}
// packages/backend/src/services/task.service.ts
import { PrismaClient } from '@prisma/client';
import type { Task, TaskCreate, TaskUpdate, TaskFilter, PaginatedResponse } from '@task-manager/shared';
const prisma = new PrismaClient();
export class TaskService {
async create(userId: string, data: TaskCreate): Promise<Task> {
return prisma.task.create({
data: {
title: data.title,
description: data.description,
status: data.status || 'pending',
priority: data.priority || 'medium',
dueDate: data.dueDate,
userId
}
}) as Promise<Task>;
}
async findAll(
userId: string,
filter: TaskFilter = {},
page = 1,
pageSize = 20
): Promise<PaginatedResponse<Task>> {
const where: any = { userId };
if (filter.status) where.status = filter.status;
if (filter.priority) where.priority = filter.priority;
if (filter.search) {
where.OR = [
{ title: { contains: filter.search, mode: 'insensitive' } },
{ description: { contains: filter.search, mode: 'insensitive' } }
];
}
const [tasks, total] = await Promise.all([
prisma.task.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: 'desc' }
}),
prisma.task.count({ where })
]);
return {
data: tasks as Task[],
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize)
};
}
async findById(id: string, userId: string): Promise<Task | null> {
return prisma.task.findFirst({
where: { id, userId }
}) as Promise<Task | null>;
}
async update(id: string, userId: string, data: TaskUpdate): Promise<Task> {
// التحقق من الملكية
const task = await this.findById(id, userId);
if (!task) {
throw new Error('Task not found');
}
return prisma.task.update({
where: { id },
data
}) as Promise<Task>;
}
async delete(id: string, userId: string): Promise<void> {
// التحقق من الملكية
const task = await this.findById(id, userId);
if (!task) {
throw new Error('Task not found');
}
await prisma.task.delete({ where: { id } });
}
}
الخطوة 5: وسيط الخلفية
// packages/backend/src/middleware/auth.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { AuthService } from '../services/auth.service';
const authService = new AuthService();
export interface AuthRequest extends Request {
userId?: string;
}
export function authMiddleware(
req: AuthRequest,
res: Response,
next: NextFunction
): void {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({ error: 'No token provided' });
return;
}
const token = authHeader.substring(7);
const { userId } = authService.verifyAccessToken(token);
req.userId = userId;
next();
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
}
// packages/backend/src/middleware/error.middleware.ts
import { Request, Response, NextFunction } from 'express';
import type { ApiError } from '@task-manager/shared';
export function errorMiddleware(
error: Error,
req: Request,
res: Response,
next: NextFunction
): void {
console.error('Error:', error);
const apiError: ApiError = {
error: error.message || 'Internal server error',
statusCode: 500
};
res.status(apiError.statusCode).json(apiError);
}
ملاحظة: نستخدم هيكل monorepo مع أنواع مشتركة بين الخلفية والواجهة الأمامية. هذا يضمن أمان الأنواع عبر التطبيق بأكمله ويزيل انحراف الأنواع.
الخطوة 6: مسارات الخلفية
// packages/backend/src/routes/auth.routes.ts
import { Router } from 'express';
import { AuthService } from '../services/auth.service';
import type { UserCreate, UserLogin, ApiResponse, AuthResponse } from '@task-manager/shared';
const router = Router();
const authService = new AuthService();
router.post('/register', async (req, res, next) => {
try {
const data: UserCreate = req.body;
const user = await authService.register(data);
const tokens = authService.generateTokens(user.id);
const response: ApiResponse<AuthResponse> = {
data: { user, tokens }
};
res.status(201).json(response);
} catch (error) {
next(error);
}
});
router.post('/login', async (req, res, next) => {
try {
const data: UserLogin = req.body;
const result = await authService.login(data);
const response: ApiResponse<AuthResponse> = {
data: result
};
res.json(response);
} catch (error) {
next(error);
}
});
export default router;
// packages/backend/src/routes/task.routes.ts
import { Router } from 'express';
import { TaskService } from '../services/task.service';
import { authMiddleware, AuthRequest } from '../middleware/auth.middleware';
import type { TaskCreate, TaskUpdate, ApiResponse, Task, PaginatedResponse } from '@task-manager/shared';
const router = Router();
const taskService = new TaskService();
// جميع المسارات تتطلب مصادقة
router.use(authMiddleware);
router.post('/', async (req: AuthRequest, res, next) => {
try {
const data: TaskCreate = req.body;
const task = await taskService.create(req.userId!, data);
const response: ApiResponse<Task> = { data: task };
res.status(201).json(response);
} catch (error) {
next(error);
}
});
router.get('/', async (req: AuthRequest, res, next) => {
try {
const { page, pageSize, ...filter } = req.query;
const result = await taskService.findAll(
req.userId!,
filter as any,
page ? parseInt(page as string) : undefined,
pageSize ? parseInt(pageSize as string) : undefined
);
const response: ApiResponse<PaginatedResponse<Task>> = { data: result };
res.json(response);
} catch (error) {
next(error);
}
});
router.get('/:id', async (req: AuthRequest, res, next) => {
try {
const task = await taskService.findById(req.params.id, req.userId!);
if (!task) {
res.status(404).json({ error: 'Task not found' });
return;
}
const response: ApiResponse<Task> = { data: task };
res.json(response);
} catch (error) {
next(error);
}
});
router.put('/:id', async (req: AuthRequest, res, next) => {
try {
const data: TaskUpdate = req.body;
const task = await taskService.update(req.params.id, req.userId!, data);
const response: ApiResponse<Task> = { data: task };
res.json(response);
} catch (error) {
next(error);
}
});
router.delete('/:id', async (req: AuthRequest, res, next) => {
try {
await taskService.delete(req.params.id, req.userId!);
res.status(204).send();
} catch (error) {
next(error);
}
});
export default router;
نصيحة: لاحظ كيف نستورد الأنواع من الحزمة المشتركة. TypeScript يضمن أن طلبات واستجابات الخلفية تطابق الأنواع المتوقعة، مما يمنع انحراف عقد API.
تمرين: أعد إعداد مشروعك:
- هيئ هيكل monorepo مع مساحات العمل
- أنشئ حزمة الأنواع المشتركة مع أنواع User وTask
- أعد إعداد الخلفية مع Prisma وPostgreSQL
- نفذ AuthService مع توليد رمز JWT
- نفذ TaskService مع عمليات CRUD
- أنشئ مسارات المصادقة والمهام مع الكتابة المناسبة
- اختبر نقاط نهاية API باستخدام Postman أو أدوات مماثلة
الخلاصة
في الجزء 1، أعددنا هيكل مشروع متكامل آمن من حيث النوع مع أنواع مشتركة، API خلفية، مصادقة، وإدارة مهام. استخدمنا TypeScript في كل مكان لضمان أمان الأنواع من قاعدة البيانات إلى API. في الجزء 2، سنبني الواجهة الأمامية، ننفذ ميزات الوقت الفعلي، نضيف الاختبارات، وننشر التطبيق.