feat: add clinic doctor invitation feature
- Implemented ClinicInvitationController to handle doctor invitations. - Created ClinicDoctorInvitation entity and repository for managing invitations. - Added ClinicInvitationService for business logic related to invitations. - Introduced endpoints for inviting, listing, resending, changing status, and deleting invitations. - Updated security configuration to allow public access to invitation endpoints. - Added migration for clinic_doctor_invitations table. - Enhanced DoctorRepository with a method to find doctors by mobile number. - Updated ClinicDetailPage to include invitation management UI.
This commit is contained in:
@@ -9,13 +9,14 @@ import {
|
||||
ArrowRightIcon, PencilIcon, TrashIcon,
|
||||
BuildingOffice2Icon, PhoneIcon, MapPinIcon, XMarkIcon,
|
||||
PlusIcon, CameraIcon, ChevronDownIcon,
|
||||
EnvelopeIcon, ArrowPathIcon, NoSymbolIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import type { ClinicDetail } from '../types';
|
||||
import { formatNumber } from '../lib/utils';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
@@ -33,6 +34,19 @@ const IRAN_CENTER: [number, number] = [32.4279, 53.6880];
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ClinicInvitation {
|
||||
uuid: string;
|
||||
mobile: string;
|
||||
invited_name: string | null;
|
||||
invited_specialty: string | null;
|
||||
status: 'pending' | 'accepted' | 'rejected' | 'suspended' | 'removed';
|
||||
token_used: boolean;
|
||||
invited_at: number;
|
||||
expires_at: number;
|
||||
responded_at: number | null;
|
||||
doctor: { uuid: string; name: string } | null;
|
||||
}
|
||||
|
||||
interface ClinicDoctorItem {
|
||||
id: string; uuid: string; name: string;
|
||||
gender: string | null; degree: string | null;
|
||||
@@ -478,6 +492,75 @@ function EditModal({ clinic, onClose, onSaved }: {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Invitation status badge ────────────────────────────────────────────────
|
||||
|
||||
const INV_STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
||||
pending: { label: 'در انتظار', cls: 'amber' },
|
||||
accepted: { label: 'پذیرفتهشده', cls: 'green' },
|
||||
rejected: { label: 'رد شده', cls: 'gray' },
|
||||
suspended: { label: 'تعلیق', cls: 'violet' },
|
||||
removed: { label: 'حذفشده', cls: 'gray' },
|
||||
};
|
||||
|
||||
// ── Invite modal ───────────────────────────────────────────────────────────
|
||||
|
||||
const inviteSchema = z.object({
|
||||
mobile: z.string().regex(/^09\d{9}$/, 'شماره موبایل ۱۱ رقمی با 09 شروع میشود'),
|
||||
name: z.string().optional(),
|
||||
specialty: z.string().optional(),
|
||||
});
|
||||
type InviteForm = z.infer<typeof inviteSchema>;
|
||||
|
||||
function InviteModal({ clinicUuid, onClose, onInvited }: {
|
||||
clinicUuid: string; onClose: () => void; onInvited: () => void;
|
||||
}) {
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<InviteForm>({
|
||||
resolver: zodResolver(inviteSchema),
|
||||
});
|
||||
|
||||
const inviteMut = useMutation({
|
||||
mutationFn: (d: InviteForm) =>
|
||||
api.post<ApiResponse<ClinicInvitation>>(`/api/v1/admin/clinic/${clinicUuid}/invite-doctor`, d),
|
||||
onSuccess: () => { toast.success('دعوتنامه ارسال شد'); onInvited(); onClose(); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={onClose}>
|
||||
<div className="modal" style={{ maxWidth: 420 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<b>دعوت پزشک به کلینیک</b>
|
||||
<button className="mini-btn" onClick={onClose}><XMarkIcon style={{ width: 16, height: 16 }} /></button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(d => inviteMut.mutate(d))}>
|
||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>شماره موبایل پزشک *</label>
|
||||
<input className="input" dir="ltr" placeholder="09xxxxxxxxx" {...register('mobile')} />
|
||||
{errors.mobile && <div className="err-text">{errors.mobile.message}</div>}
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>نام پزشک (اختیاری)</label>
|
||||
<input className="input" placeholder="دکتر نام و نام خانوادگی" {...register('name')} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>تخصص (اختیاری)</label>
|
||||
<input className="input" placeholder="مثال: قلب و عروق" {...register('specialty')} />
|
||||
</div>
|
||||
<p className="muted" style={{ fontSize: 12 }}>پیامک دعوتنامه با لینک ۷۲ ساعته ارسال میشود</p>
|
||||
</div>
|
||||
<div className="modal-foot">
|
||||
<button type="button" className="btn ghost sm" onClick={onClose}>انصراف</button>
|
||||
<button type="submit" className="btn primary sm" disabled={inviteMut.isPending}>
|
||||
{inviteMut.isPending ? 'در حال ارسال...' : 'ارسال دعوتنامه'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Page ──────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ClinicDetailPage() {
|
||||
@@ -486,6 +569,8 @@ export default function ClinicDetailPage() {
|
||||
const qc = useQueryClient();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [doctorsTab, setDoctorsTab] = useState<'doctors' | 'invitations'>('doctors');
|
||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||
const galleryInputRef = useRef<HTMLInputElement>(null);
|
||||
const [logoUploading, setLogoUploading] = useState(false);
|
||||
@@ -503,6 +588,12 @@ export default function ClinicDetailPage() {
|
||||
enabled: !!uuid,
|
||||
});
|
||||
|
||||
const invitationsQ = useQuery({
|
||||
queryKey: ['clinic-invitations', uuid],
|
||||
queryFn: () => api.get<PaginatedResponse<ClinicInvitation>>(`/api/v1/admin/clinic/${uuid}/invitations?limit=50`),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
|
||||
const clinic: ClinicDetail | undefined = useMemo(() => {
|
||||
const raw = data?.data;
|
||||
return (raw as any)?.data ?? raw;
|
||||
@@ -513,6 +604,8 @@ export default function ClinicDetailPage() {
|
||||
return (raw as any)?.data ?? raw ?? [];
|
||||
}, [doctorsQ.data]);
|
||||
|
||||
const invitationList: ClinicInvitation[] = invitationsQ.data?.data ?? [];
|
||||
|
||||
const toggleMut = useMutation({
|
||||
mutationFn: () => api.patch<ApiResponse<any>>(`/api/v1/admin/clinic/${uuid}/status`, {}),
|
||||
onSuccess: () => {
|
||||
@@ -529,6 +622,25 @@ export default function ClinicDetailPage() {
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const resendInvMut = useMutation({
|
||||
mutationFn: (invUuid: string) => api.post<ApiResponse<any>>(`/api/v1/admin/clinic/invitation/${invUuid}/resend`, {}),
|
||||
onSuccess: () => { toast.success('پیامک مجدداً ارسال شد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const changeInvStatusMut = useMutation({
|
||||
mutationFn: ({ invUuid, status }: { invUuid: string; status: string }) =>
|
||||
api.patch<ApiResponse<any>>(`/api/v1/admin/clinic/invitation/${invUuid}/status`, { status }),
|
||||
onSuccess: () => { toast.success('وضعیت دعوتنامه تغییر کرد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const deleteInvMut = useMutation({
|
||||
mutationFn: (invUuid: string) => api.delete<ApiResponse<null>>(`/api/v1/admin/clinic/invitation/${invUuid}`),
|
||||
onSuccess: () => { toast.success('دعوتنامه حذف شد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const handleLogoUpload = async (file: File) => {
|
||||
setLogoUploading(true);
|
||||
try {
|
||||
@@ -567,7 +679,7 @@ export default function ClinicDetailPage() {
|
||||
const json = await res.json();
|
||||
const url = json?.data?.url;
|
||||
if (url && clinic) {
|
||||
const existing = (clinic.images_clinic ?? []).map(img => img.url);
|
||||
const existing = (clinic.images_clinic ?? []).filter(img => img?.url).map(img => img.url);
|
||||
await api.patch(`/api/v1/clinic/${uuid}`, { image_clinic: [...existing, url] });
|
||||
toast.success('تصویر اضافه شد');
|
||||
qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] });
|
||||
@@ -695,39 +807,122 @@ export default function ClinicDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Doctors */}
|
||||
{/* Doctors + Invitations card */}
|
||||
<div className="card card-pad">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||||
<b style={{ fontSize: 14 }}>پزشکان ({formatNumber(doctorList.length)})</b>
|
||||
{/* Card header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<div className="seg">
|
||||
<button className={doctorsTab === 'doctors' ? 'active' : ''} onClick={() => setDoctorsTab('doctors')}>
|
||||
پزشکان ({formatNumber(doctorList.length)})
|
||||
</button>
|
||||
<button className={doctorsTab === 'invitations' ? 'active' : ''} onClick={() => setDoctorsTab('invitations')}>
|
||||
دعوتنامهها ({formatNumber(invitationList.length)})
|
||||
</button>
|
||||
</div>
|
||||
<button className="btn primary sm" onClick={() => setInviteOpen(true)}>
|
||||
<EnvelopeIcon style={{ width: 14, height: 14 }} /> دعوت پزشک
|
||||
</button>
|
||||
</div>
|
||||
{doctorList.length === 0 ? (
|
||||
<div className="empty" style={{ padding: '20px 0' }}>
|
||||
<p className="muted">هیچ پزشکی به این کلینیک متصل نیست</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{doctorList.map(doc => {
|
||||
const dHue = HUES_LIST[(doc.uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length];
|
||||
const img = doc.img?.[0]?.url;
|
||||
return (
|
||||
<div key={doc.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8, background: 'var(--surface-2, var(--bg))' }}>
|
||||
{img
|
||||
? <img src={img} alt="" className="avatar sm" style={{ objectFit: 'cover', flexShrink: 0 }} />
|
||||
: <div className="avatar sm" style={{ background: `linear-gradient(145deg, oklch(0.62 0.15 ${dHue}), oklch(0.48 0.16 ${dHue}))`, flexShrink: 0 }}>{doc.name?.[0] ?? '?'}</div>
|
||||
}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>{doc.name}</div>
|
||||
{doc.specialties?.length > 0 && (
|
||||
<div className="muted" style={{ fontSize: 11 }}>{doc.specialties.map(s => s.name).join('، ')}</div>
|
||||
)}
|
||||
|
||||
{/* Doctors tab */}
|
||||
{doctorsTab === 'doctors' && (
|
||||
doctorList.length === 0 ? (
|
||||
<div className="empty" style={{ padding: '20px 0' }}>
|
||||
<p className="muted">هیچ پزشکی به این کلینیک متصل نیست</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{doctorList.map(doc => {
|
||||
const dHue = HUES_LIST[(doc.uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length];
|
||||
const img = doc.img?.[0]?.url;
|
||||
return (
|
||||
<div key={doc.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8, background: 'var(--surface-2, var(--bg))' }}>
|
||||
{img
|
||||
? <img src={img} alt="" className="avatar sm" style={{ objectFit: 'cover', flexShrink: 0 }} />
|
||||
: <div className="avatar sm" style={{ background: `linear-gradient(145deg, oklch(0.62 0.15 ${dHue}), oklch(0.48 0.16 ${dHue}))`, flexShrink: 0 }}>{doc.name?.[0] ?? '?'}</div>
|
||||
}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>{doc.name}</div>
|
||||
{doc.specialties?.length > 0 && (
|
||||
<div className="muted" style={{ fontSize: 11 }}>{doc.specialties.map(s => s.name).join('، ')}</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={`badge ${doc.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>
|
||||
<span className="bdot" />{doc.active ? 'فعال' : 'غیرفعال'}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`badge ${doc.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>
|
||||
<span className="bdot" />{doc.active ? 'فعال' : 'غیرفعال'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Invitations tab */}
|
||||
{doctorsTab === 'invitations' && (
|
||||
invitationList.length === 0 ? (
|
||||
<div className="empty" style={{ padding: '20px 0' }}>
|
||||
<EnvelopeIcon style={{ width: 30, height: 30 }} />
|
||||
<p className="muted">هیچ دعوتنامهای ارسال نشده</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{invitationList.map(inv => {
|
||||
const statusInfo = INV_STATUS_MAP[inv.status] ?? { label: inv.status, cls: 'gray' };
|
||||
const isExpired = !inv.token_used && inv.status === 'pending' && Date.now() / 1000 > inv.expires_at;
|
||||
return (
|
||||
<div key={inv.uuid} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 8, background: 'var(--surface-2, var(--bg))' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>{inv.invited_name ?? inv.mobile}</div>
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 3, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span className="muted" style={{ fontSize: 11, direction: 'ltr' }}>{inv.mobile}</span>
|
||||
{inv.invited_specialty && (
|
||||
<span className="muted" style={{ fontSize: 11 }}>{inv.invited_specialty}</span>
|
||||
)}
|
||||
{inv.doctor && (
|
||||
<span className="badge blue" style={{ fontSize: 11 }}><span className="bdot" />{inv.doctor.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
|
||||
<span className={`badge ${isExpired ? 'gray' : statusInfo.cls}`} style={{ fontSize: 11 }}>
|
||||
<span className="bdot" />{isExpired ? 'منقضی' : statusInfo.label}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{inv.status === 'pending' && (
|
||||
<button
|
||||
className="mini-btn"
|
||||
title="ارسال مجدد"
|
||||
disabled={resendInvMut.isPending}
|
||||
onClick={() => resendInvMut.mutate(inv.uuid)}
|
||||
>
|
||||
<ArrowPathIcon style={{ width: 13, height: 13 }} />
|
||||
</button>
|
||||
)}
|
||||
{inv.status !== 'removed' && inv.status !== 'accepted' && (
|
||||
<button
|
||||
className="mini-btn"
|
||||
title="تعلیق"
|
||||
disabled={changeInvStatusMut.isPending}
|
||||
onClick={() => changeInvStatusMut.mutate({ invUuid: inv.uuid, status: inv.status === 'suspended' ? 'pending' : 'suspended' })}
|
||||
>
|
||||
<NoSymbolIcon style={{ width: 13, height: 13 }} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="mini-btn danger"
|
||||
title="حذف"
|
||||
disabled={deleteInvMut.isPending}
|
||||
onClick={() => deleteInvMut.mutate(inv.uuid)}
|
||||
>
|
||||
<TrashIcon style={{ width: 13, height: 13 }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -748,7 +943,7 @@ export default function ClinicDetailPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))', gap: 8 }}>
|
||||
{clinic.images_clinic.map((img, i) => (
|
||||
{clinic.images_clinic.filter(img => img?.url).map((img, i) => (
|
||||
<div key={i} style={{ borderRadius: 8, overflow: 'hidden', aspectRatio: '1', background: 'var(--surface-2, var(--bg))' }}>
|
||||
<img src={img.url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</div>
|
||||
@@ -829,6 +1024,15 @@ export default function ClinicDetailPage() {
|
||||
onSaved={() => { qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] }); qc.invalidateQueries({ queryKey: ['admin-clinics'] }); }} />
|
||||
)}
|
||||
|
||||
{/* Invite doctor modal */}
|
||||
{inviteOpen && uuid && (
|
||||
<InviteModal
|
||||
clinicUuid={uuid}
|
||||
onClose={() => setInviteOpen(false)}
|
||||
onInvited={() => { qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); setDoctorsTab('invitations'); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirm */}
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
|
||||
@@ -33,7 +33,7 @@ security:
|
||||
provider: api_doc_provider
|
||||
|
||||
public_endpoints:
|
||||
pattern: ^/(api/v1/user/(send-code|verify-code|register)|oauth/token$|session/token|api/v1/categorys/|api/v1/doctors$|api/v1/clinics$|api/v1/clinic/doctor-list/|api/v1/clinic-pro/doctor-addresses/|api/v1/appointment-slots|api/v1/comments/|api/v1/rate/|api/v1/blogs$)
|
||||
pattern: ^/(api/v1/user/(send-code|verify-code|register)|oauth/token$|session/token|api/v1/categorys/|api/v1/doctors$|api/v1/clinics$|api/v1/clinic/doctor-list/|api/v1/clinic-pro/doctor-addresses/|api/v1/appointment-slots|api/v1/comments/|api/v1/rate/|api/v1/blogs$|api/v1/clinic-invitation/)
|
||||
stateless: true
|
||||
security: false
|
||||
|
||||
@@ -80,6 +80,7 @@ security:
|
||||
- path: '^/api/v1/clinic/[^/]+$'
|
||||
methods: [GET]
|
||||
roles: PUBLIC_ACCESS
|
||||
- { path: ^/api/v1/clinic-invitation/, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/user/\d+$, methods: [DELETE], roles: ROLE_ADMIN }
|
||||
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
||||
- { path: ^/oauth/userinfo, roles: IS_AUTHENTICATED_FULLY }
|
||||
|
||||
@@ -77,3 +77,7 @@ services:
|
||||
App\Insurance\Controller\InsuranceController:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
App\ClinicInvitation\Service\ClinicInvitationService:
|
||||
arguments:
|
||||
$appUrl: '%env(APP_BASE_URL)%'
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260610183655 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE clinic_doctor_invitations (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, mobile VARCHAR(20) NOT NULL, invited_name VARCHAR(255) DEFAULT NULL, invited_specialty VARCHAR(255) DEFAULT NULL, token VARCHAR(128) NOT NULL, token_used TINYINT NOT NULL, status VARCHAR(20) NOT NULL, invited_at INT NOT NULL, expires_at INT NOT NULL, responded_at INT DEFAULT NULL, clinic_id INT NOT NULL, invited_by_id INT NOT NULL, doctor_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_26DCCFEBD17F50A6 (uuid), UNIQUE INDEX UNIQ_26DCCFEB5F37A13B (token), INDEX IDX_26DCCFEBA7B4A7E3 (invited_by_id), INDEX IDX_26DCCFEB87F4FB17 (doctor_id), INDEX idx_cdi_token (token), INDEX idx_cdi_clinic (clinic_id), INDEX idx_cdi_mobile (mobile), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('ALTER TABLE clinic_doctor_invitations ADD CONSTRAINT FK_26DCCFEBCC22AD4 FOREIGN KEY (clinic_id) REFERENCES clinics (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE clinic_doctor_invitations ADD CONSTRAINT FK_26DCCFEBA7B4A7E3 FOREIGN KEY (invited_by_id) REFERENCES users (id)');
|
||||
$this->addSql('ALTER TABLE clinic_doctor_invitations ADD CONSTRAINT FK_26DCCFEB87F4FB17 FOREIGN KEY (doctor_id) REFERENCES doctors (id) ON DELETE SET NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE clinic_doctor_invitations DROP FOREIGN KEY FK_26DCCFEBCC22AD4');
|
||||
$this->addSql('ALTER TABLE clinic_doctor_invitations DROP FOREIGN KEY FK_26DCCFEBA7B4A7E3');
|
||||
$this->addSql('ALTER TABLE clinic_doctor_invitations DROP FOREIGN KEY FK_26DCCFEB87F4FB17');
|
||||
$this->addSql('DROP TABLE clinic_doctor_invitations');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\ClinicInvitation\Controller;
|
||||
|
||||
use App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository;
|
||||
use App\ClinicInvitation\Service\ClinicInvitationService;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class ClinicInvitationController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ClinicInvitationService $invitationService,
|
||||
private readonly ClinicDoctorInvitationRepository $invRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
) {}
|
||||
|
||||
// ── Admin endpoints ──────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/admin/clinic/{uuid}/invite-doctor', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function inviteDoctor(string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$clinic = $this->clinicRepo->findByUuid($uuid);
|
||||
if (!$clinic) {
|
||||
throw new AppException('ERR_NOT_FOUND_001', 'کلینیک یافت نشد', 404);
|
||||
}
|
||||
|
||||
$body = json_decode($request->getContent(), true) ?? [];
|
||||
$mobile = trim($body['mobile'] ?? '');
|
||||
$name = !empty($body['name']) ? trim($body['name']) : null;
|
||||
$specialty = !empty($body['specialty']) ? trim($body['specialty']) : null;
|
||||
|
||||
if (!$mobile || !preg_match('/^09\d{9}$/', $mobile)) {
|
||||
throw new AppException('ERR_VALIDATION_001', 'شماره موبایل نامعتبر است', 422, 'mobile');
|
||||
}
|
||||
|
||||
$inv = $this->invitationService->invite($clinic, $this->getUser(), $mobile, $name, $specialty);
|
||||
|
||||
return $this->success($inv->toArray(), 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/clinic/{uuid}/invitations', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function listInvitations(string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$clinic = $this->clinicRepo->findByUuid($uuid);
|
||||
if (!$clinic) {
|
||||
throw new AppException('ERR_NOT_FOUND_001', 'کلینیک یافت نشد', 404);
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
|
||||
|
||||
$qb = $this->invRepo->createQueryBuilder('i')
|
||||
->leftJoin('i.doctor', 'd')
|
||||
->where('i.clinic = :clinic')
|
||||
->setParameter('clinic', $clinic)
|
||||
->orderBy('i.invitedAt', 'DESC');
|
||||
|
||||
$total = (clone $qb)->select('COUNT(i.id)')->getQuery()->getSingleScalarResult();
|
||||
|
||||
$items = $qb->setFirstResult(($page - 1) * $limit)
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$data = array_map(fn($inv) => $inv->toArray(), $items);
|
||||
|
||||
return $this->paginated($data, (int) $total, $page, $limit);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/clinic/invitation/{invUuid}/resend', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function resendInvitation(string $invUuid): JsonResponse
|
||||
{
|
||||
$inv = $this->invRepo->findOneBy(['uuid' => $invUuid]);
|
||||
if (!$inv) {
|
||||
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
|
||||
}
|
||||
|
||||
$this->invitationService->resend($inv);
|
||||
|
||||
return $this->success(['message' => 'پیامک مجدداً ارسال شد']);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/clinic/invitation/{invUuid}/status', methods: ['PATCH'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function changeInvitationStatus(string $invUuid, Request $request): JsonResponse
|
||||
{
|
||||
$inv = $this->invRepo->findOneBy(['uuid' => $invUuid]);
|
||||
if (!$inv) {
|
||||
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
|
||||
}
|
||||
|
||||
$body = json_decode($request->getContent(), true) ?? [];
|
||||
$status = $body['status'] ?? '';
|
||||
|
||||
$this->invitationService->changeStatus($inv, $status);
|
||||
|
||||
return $this->success(['status' => $inv->getStatus()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/clinic/invitation/{invUuid}', methods: ['DELETE'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function deleteInvitation(string $invUuid): JsonResponse
|
||||
{
|
||||
$inv = $this->invRepo->findOneBy(['uuid' => $invUuid]);
|
||||
if (!$inv) {
|
||||
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
|
||||
}
|
||||
|
||||
$this->invitationService->delete($inv);
|
||||
|
||||
return $this->success(null, 204);
|
||||
}
|
||||
|
||||
// ── Public endpoints ──────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/clinic-invitation/{token}', methods: ['GET'])]
|
||||
public function viewInvitation(string $token): JsonResponse
|
||||
{
|
||||
$inv = $this->invRepo->findByToken($token);
|
||||
if (!$inv) {
|
||||
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
|
||||
}
|
||||
|
||||
$clinic = $inv->getClinic();
|
||||
|
||||
return $this->success([
|
||||
'invitation' => $inv->toArray(),
|
||||
'clinic' => [
|
||||
'uuid' => $clinic->getUuid(),
|
||||
'name' => $clinic->getName(),
|
||||
'logo' => $clinic->getClinicLogo(),
|
||||
],
|
||||
'is_usable' => $inv->isUsable(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/clinic-invitation/{token}/accept', methods: ['POST'])]
|
||||
public function acceptInvitation(string $token): JsonResponse
|
||||
{
|
||||
$inv = $this->invRepo->findByToken($token);
|
||||
if (!$inv) {
|
||||
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
|
||||
}
|
||||
|
||||
$this->invitationService->accept($inv);
|
||||
|
||||
return $this->success(['message' => 'دعوتنامه پذیرفته شد']);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/clinic-invitation/{token}/reject', methods: ['POST'])]
|
||||
public function rejectInvitation(string $token): JsonResponse
|
||||
{
|
||||
$inv = $this->invRepo->findByToken($token);
|
||||
if (!$inv) {
|
||||
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
|
||||
}
|
||||
|
||||
$this->invitationService->reject($inv);
|
||||
|
||||
return $this->success(['message' => 'دعوتنامه رد شد']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\ClinicInvitation\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: ClinicDoctorInvitationRepository::class)]
|
||||
#[ORM\Table(name: 'clinic_doctor_invitations')]
|
||||
#[ORM\Index(columns: ['token'], name: 'idx_cdi_token')]
|
||||
#[ORM\Index(columns: ['clinic_id'], name: 'idx_cdi_clinic')]
|
||||
#[ORM\Index(columns: ['mobile'], name: 'idx_cdi_mobile')]
|
||||
class ClinicDoctorInvitation
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_ACCEPTED = 'accepted';
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
public const STATUS_SUSPENDED = 'suspended';
|
||||
public const STATUS_REMOVED = 'removed';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Clinic::class)]
|
||||
#[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Clinic $clinic;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'invited_by_id', referencedColumnName: 'id', nullable: false)]
|
||||
private User $invitedBy;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Doctor $doctor = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 20)]
|
||||
private string $mobile;
|
||||
|
||||
#[ORM\Column(name: 'invited_name', type: 'string', length: 255, nullable: true)]
|
||||
private ?string $invitedName = null;
|
||||
|
||||
#[ORM\Column(name: 'invited_specialty', type: 'string', length: 255, nullable: true)]
|
||||
private ?string $invitedSpecialty = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 128, unique: true)]
|
||||
private string $token;
|
||||
|
||||
#[ORM\Column(name: 'token_used', type: 'boolean')]
|
||||
private bool $tokenUsed = false;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 20)]
|
||||
private string $status = self::STATUS_PENDING;
|
||||
|
||||
#[ORM\Column(name: 'invited_at', type: 'integer')]
|
||||
private int $invitedAt;
|
||||
|
||||
#[ORM\Column(name: 'expires_at', type: 'integer')]
|
||||
private int $expiresAt;
|
||||
|
||||
#[ORM\Column(name: 'responded_at', type: 'integer', nullable: true)]
|
||||
private ?int $respondedAt = null;
|
||||
|
||||
public function __construct(Clinic $clinic, User $invitedBy, string $mobile)
|
||||
{
|
||||
$this->uuid = \Symfony\Component\Uid\Uuid::v4()->toRfc4122();
|
||||
$this->clinic = $clinic;
|
||||
$this->invitedBy = $invitedBy;
|
||||
$this->mobile = $mobile;
|
||||
$this->token = bin2hex(random_bytes(48));
|
||||
$this->invitedAt = time();
|
||||
$this->expiresAt = $this->invitedAt + 72 * 3600;
|
||||
}
|
||||
|
||||
public function isUsable(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING
|
||||
&& !$this->tokenUsed
|
||||
&& time() < $this->expiresAt;
|
||||
}
|
||||
|
||||
public function markUsed(): void
|
||||
{
|
||||
$this->tokenUsed = true;
|
||||
$this->respondedAt = time();
|
||||
}
|
||||
|
||||
public function refresh(): void
|
||||
{
|
||||
$this->token = bin2hex(random_bytes(48));
|
||||
$this->tokenUsed = false;
|
||||
$this->invitedAt = time();
|
||||
$this->expiresAt = $this->invitedAt + 72 * 3600;
|
||||
$this->status = self::STATUS_PENDING;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'mobile' => $this->mobile,
|
||||
'invited_name' => $this->invitedName,
|
||||
'invited_specialty' => $this->invitedSpecialty,
|
||||
'status' => $this->status,
|
||||
'token_used' => $this->tokenUsed,
|
||||
'invited_at' => $this->invitedAt,
|
||||
'expires_at' => $this->expiresAt,
|
||||
'responded_at' => $this->respondedAt,
|
||||
'doctor' => $this->doctor ? [
|
||||
'uuid' => $this->doctor->getUuid(),
|
||||
'name' => $this->doctor->getName(),
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getClinic(): Clinic { return $this->clinic; }
|
||||
public function getDoctor(): ?Doctor { return $this->doctor; }
|
||||
public function getMobile(): string { return $this->mobile; }
|
||||
public function getToken(): string { return $this->token; }
|
||||
public function getStatus(): string { return $this->status; }
|
||||
public function getInvitedAt(): int { return $this->invitedAt; }
|
||||
public function getExpiresAt(): int { return $this->expiresAt; }
|
||||
public function isTokenUsed(): bool { return $this->tokenUsed; }
|
||||
public function getInvitedName(): ?string { return $this->invitedName; }
|
||||
public function getInvitedSpecialty(): ?string { return $this->invitedSpecialty; }
|
||||
|
||||
public function setDoctor(?Doctor $doctor): void { $this->doctor = $doctor; }
|
||||
public function setStatus(string $status): void { $this->status = $status; }
|
||||
public function setInvitedName(?string $n): void { $this->invitedName = $n; }
|
||||
public function setInvitedSpecialty(?string $s): void { $this->invitedSpecialty = $s; }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\ClinicInvitation\Repository;
|
||||
|
||||
use App\ClinicInvitation\Entity\ClinicDoctorInvitation;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class ClinicDoctorInvitationRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ClinicDoctorInvitation::class);
|
||||
}
|
||||
|
||||
public function findByToken(string $token): ?ClinicDoctorInvitation
|
||||
{
|
||||
return $this->findOneBy(['token' => $token]);
|
||||
}
|
||||
|
||||
public function findPendingByMobileAndClinic(string $mobile, int $clinicId): ?ClinicDoctorInvitation
|
||||
{
|
||||
return $this->createQueryBuilder('i')
|
||||
->where('i.mobile = :mobile')
|
||||
->andWhere('i.clinic = :clinicId')
|
||||
->andWhere('i.status = :status')
|
||||
->setParameter('mobile', $mobile)
|
||||
->setParameter('clinicId', $clinicId)
|
||||
->setParameter('status', ClinicDoctorInvitation::STATUS_PENDING)
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function save(ClinicDoctorInvitation $invitation): void
|
||||
{
|
||||
$em = $this->getEntityManager();
|
||||
$em->persist($invitation);
|
||||
$em->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\ClinicInvitation\Service;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicInvitation\Entity\ClinicDoctorInvitation;
|
||||
use App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Sms\Service\SmsService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
class ClinicInvitationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ClinicDoctorInvitationRepository $repo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly SmsService $smsService,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly string $appUrl,
|
||||
) {}
|
||||
|
||||
public function invite(Clinic $clinic, User $invitedBy, string $mobile, ?string $name, ?string $specialty): ClinicDoctorInvitation
|
||||
{
|
||||
$existing = $this->repo->findPendingByMobileAndClinic($mobile, $clinic->getId());
|
||||
if ($existing !== null) {
|
||||
throw new AppException('ERR_CONFLICT_001', 'این شماره قبلاً برای این کلینیک دعوت شده است', 409);
|
||||
}
|
||||
|
||||
$inv = new ClinicDoctorInvitation($clinic, $invitedBy, $mobile);
|
||||
$inv->setInvitedName($name);
|
||||
$inv->setInvitedSpecialty($specialty);
|
||||
|
||||
$doctor = $this->doctorRepo->findOneByMobile($mobile);
|
||||
if ($doctor !== null) {
|
||||
$inv->setDoctor($doctor);
|
||||
}
|
||||
|
||||
$this->repo->save($inv);
|
||||
$this->sendSms($inv, $clinic);
|
||||
|
||||
return $inv;
|
||||
}
|
||||
|
||||
public function resend(ClinicDoctorInvitation $inv): void
|
||||
{
|
||||
if (in_array($inv->getStatus(), [ClinicDoctorInvitation::STATUS_REMOVED, ClinicDoctorInvitation::STATUS_ACCEPTED], true)) {
|
||||
throw new AppException('ERR_CONFLICT_001', 'امکان ارسال مجدد دعوتنامه وجود ندارد', 409);
|
||||
}
|
||||
|
||||
$inv->refresh();
|
||||
$this->em->flush();
|
||||
$this->sendSms($inv, $inv->getClinic());
|
||||
}
|
||||
|
||||
public function changeStatus(ClinicDoctorInvitation $inv, string $status): void
|
||||
{
|
||||
$allowed = [ClinicDoctorInvitation::STATUS_SUSPENDED, ClinicDoctorInvitation::STATUS_REMOVED];
|
||||
if (!in_array($status, $allowed, true)) {
|
||||
throw new AppException('ERR_VALIDATION_001', 'وضعیت نامعتبر است', 422);
|
||||
}
|
||||
$inv->setStatus($status);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
public function delete(ClinicDoctorInvitation $inv): void
|
||||
{
|
||||
$this->em->remove($inv);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
public function accept(ClinicDoctorInvitation $inv): void
|
||||
{
|
||||
if (!$inv->isUsable()) {
|
||||
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه منقضی یا غیرمعتبر است', 410);
|
||||
}
|
||||
$inv->setStatus(ClinicDoctorInvitation::STATUS_ACCEPTED);
|
||||
$inv->markUsed();
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
public function reject(ClinicDoctorInvitation $inv): void
|
||||
{
|
||||
if (!$inv->isUsable()) {
|
||||
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه منقضی یا غیرمعتبر است', 410);
|
||||
}
|
||||
$inv->setStatus(ClinicDoctorInvitation::STATUS_REJECTED);
|
||||
$inv->markUsed();
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
private function sendSms(ClinicDoctorInvitation $inv, Clinic $clinic): void
|
||||
{
|
||||
$clinicName = $clinic->getName() ?? 'کلینیک';
|
||||
$link = rtrim($this->appUrl, '/') . '/clinic-invitation/' . $inv->getToken();
|
||||
|
||||
$message = "دکتر گرامی، کلینیک {$clinicName} شما را برای همکاری دعوت کرده است.\n"
|
||||
. "برای بررسی: {$link}\n"
|
||||
. "این لینک تا ۷۲ ساعت معتبر است.";
|
||||
|
||||
$this->smsService->dispatchAsync($inv->getMobile(), $message);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,17 @@ class DoctorRepository extends ServiceEntityRepository
|
||||
return $this->findOneBy(['user' => $user]);
|
||||
}
|
||||
|
||||
public function findOneByMobile(string $mobile): ?Doctor
|
||||
{
|
||||
return $this->createQueryBuilder('d')
|
||||
->join('d.user', 'u')
|
||||
->where('u.mobileNumber = :mobile')
|
||||
->setParameter('mobile', $mobile)
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function findWithFilters(array $filters): array
|
||||
{
|
||||
$page = max(1, (int) ($filters['page'] ?? 1));
|
||||
|
||||
Reference in New Issue
Block a user