feat: add TourProgressController and related entities for user tour progress tracking

- Implemented TourProgressController to handle API endpoints for tracking guided tours seen by users.
- Created UserTourProgress entity to store the highest version of tours seen by each user.
- Developed UserTourProgressRepository for database interactions related to user tour progress.
- Introduced TourProgressService to manage business logic for marking tours as seen and retrieving seen maps.
- Added comprehensive tests for API endpoints and entity behavior to ensure functionality and data integrity.
This commit is contained in:
hamed
2026-08-10 09:34:14 +03:30
parent 7716b40f6a
commit 20c8eaaad9
27 changed files with 16088 additions and 477 deletions
+11
View File
@@ -0,0 +1,11 @@
import type { TourDefinition } from './types';
import { appointmentsTour } from './tours/appointments';
/** هر صفحه تور خودش را در tours/ دارد و فقط اینجا ثبت می‌شود. */
export const TOURS: Record<string, TourDefinition> = {
[appointmentsTour.id]: appointmentsTour,
};
export function getTour(id?: string): TourDefinition | null {
return id ? TOURS[id] ?? null : null;
}
+14
View File
@@ -0,0 +1,14 @@
import type { TourStep } from './types';
export function anchorSelector(anchor: string): string {
return `[data-tour="${anchor}"]`;
}
/**
* فقط استپ‌هایی می‌مانند که المانشان همین حالا در DOM هست.
* صفحات پنل نقش‌محورند: «افزودن نوبت» برای منشیِ بدون مجوز اصلاً رندر نمی‌شود و
* تور نباید روی المان غایب گیر کند یا شمارندهٔ اشتباه نشان بدهد.
*/
export function resolveSteps(steps: TourStep[], root: ParentNode = document): TourStep[] {
return steps.filter((s) => root.querySelector(anchorSelector(s.anchor)) !== null);
}
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect, afterEach } from 'vitest';
import { anchorSelector, resolveSteps } from './resolveSteps';
import { getTour, TOURS } from './registry';
import type { TourStep } from './types';
const STEPS: TourStep[] = [
{ anchor: 'one', title: 'یک', body: '…' },
{ anchor: 'two', title: 'دو', body: '…' },
{ anchor: 'three', title: 'سه', body: '…' },
];
function mount(...anchors: string[]) {
document.body.innerHTML = anchors.map((a) => `<div data-tour="${a}"></div>`).join('');
}
afterEach(() => {
document.body.innerHTML = '';
});
describe('resolveSteps', () => {
it('استپ‌های موجود را با حفظ ترتیب نگه می‌دارد', () => {
mount('three', 'one');
expect(resolveSteps(STEPS).map((s) => s.anchor)).toEqual(['one', 'three']);
});
it('وقتی هیچ المانی نیست، خروجی خالی است', () => {
expect(resolveSteps(STEPS)).toEqual([]);
});
it('آرایهٔ خالی خروجی خالی می‌دهد', () => {
mount('one');
expect(resolveSteps([])).toEqual([]);
});
it('سلکتور از روی anchor ساخته می‌شود', () => {
expect(anchorSelector('appointments-stats')).toBe('[data-tour="appointments-stats"]');
});
});
describe('registry', () => {
it('برای شناسهٔ ناشناس یا خالی null می‌دهد', () => {
expect(getTour('does-not-exist')).toBeNull();
expect(getTour(undefined)).toBeNull();
});
it('تور نوبت‌ها ثبت شده است', () => {
expect(getTour('appointments')?.id).toBe('appointments');
});
it('هر تور نسخهٔ معتبر و anchorهای بدون تکرار دارد', () => {
for (const [id, tour] of Object.entries(TOURS)) {
expect(tour.id).toBe(id);
expect(tour.version).toBeGreaterThanOrEqual(1);
expect(tour.steps.length).toBeGreaterThan(0);
const anchors = tour.steps.map((s) => s.anchor);
expect(new Set(anchors).size).toBe(anchors.length);
}
});
});
@@ -0,0 +1,56 @@
import type { TourDefinition } from '../types';
export const appointmentsTour: TourDefinition = {
id: 'appointments',
version: 1,
steps: [
{
anchor: 'appointments-stats',
title: 'آمار همین روز',
body: 'تعداد کل نوبت‌ها، انجام‌شده، در انتظار و لغوشده — همه برای روزی که پایین انتخاب کرده‌اید.',
side: 'bottom',
},
{
anchor: 'appointments-date',
title: 'انتخاب روز',
body: 'با فلش‌ها یک روز جلو و عقب بروید، یا از آیکون تقویم یک تاریخ را مستقیم انتخاب کنید.',
side: 'bottom',
},
{
anchor: 'appointments-service',
title: 'فیلتر خدمت',
body: 'فقط نوبت‌های یک خدمت مشخص را ببینید. برای روزهای شلوغ مفید است.',
side: 'bottom',
},
{
anchor: 'appointments-view',
title: 'تایم‌لاین یا جدول',
body: 'تایم‌لاین ساعت‌های روز را کنار هم نشان می‌دهد. جدول همان نوبت‌ها را فهرست‌وار می‌آورد.',
side: 'bottom',
},
{
anchor: 'appointments-filters',
title: 'فیلترهای بیشتر',
body: 'فیلتر بر اساس وضعیت نوبت، بیمه و بیمار. وقتی فیلتری فعال باشد، رنگ این دکمه عوض می‌شود.',
side: 'bottom',
},
{
anchor: 'appointments-new',
title: 'ثبت نوبت جدید',
body: 'نوبت را برای همان روز و همان پزشکِ انتخاب‌شده باز می‌کند.',
side: 'bottom',
},
{
anchor: 'appointments-doctors',
title: 'تب پزشکان',
body: 'در کلینیک چندپزشکه، برنامهٔ هر پزشک را جدا ببینید.',
side: 'bottom',
},
{
anchor: 'appointments-list',
title: 'خودِ نوبت‌ها',
body: 'با کلیک روی هر نوبت وارد جزئیات آن می‌شوید و می‌توانید وضعیتش را عوض کنید.',
side: 'top',
},
],
};
+15
View File
@@ -0,0 +1,15 @@
export interface TourStep {
/** مقدار اتریبیوت data-tour روی المان هدف */
anchor: string;
title: string;
body: string;
side?: 'top' | 'bottom' | 'left' | 'right';
}
export interface TourDefinition {
/** شناسهٔ یکتا؛ هم‌نام مسیر صفحه. سرور همین را ذخیره می‌کند، پس تغییرش یعنی تور از نو دیده می‌شود. */
id: string;
/** با هر بازنویسی متن تور یکی زیاد شود تا کاربر قدیمی هم یک‌بار دیگر آن را ببیند */
version: number;
steps: TourStep[];
}