Commit 38add483 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(org-announcements): publishing reaches every member, once

Image uploads never worked (validateFile's null success was read as failure,
generatePath got three arguments, the returned URL was treated as a flag);
expiry was never saved; an Arabic-only announcement could not be stored
(title and content are NOT NULL); the list linked to an unpublish route that
did not exist.

Now: Arabic title and text are what is required, expiry is entered in Cairo
time, images go to org-media (JPEG/PNG/WebP up to 5 MB, removed from storage
when replaced, removed or the announcement is deleted). Publishing sets
published_at and sends one org_announcement notification to every active
member, in chunks of 500, with org_id and announcement_id in data; it is
logged in org_activity_log. Republishing after an unpublish never notifies
twice. "Publish now" on create needs the same permission as publish.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent af3c054a
...@@ -479,5 +479,6 @@ return [ ...@@ -479,5 +479,6 @@ return [
'organizations/{id}/announcements/{annId}/edit' => ['module' => 'org-announcements', 'action' => 'edit'], 'organizations/{id}/announcements/{annId}/edit' => ['module' => 'org-announcements', 'action' => 'edit'],
'organizations/{id}/announcements/{annId}/update' => ['module' => 'org-announcements', 'action' => 'update'], 'organizations/{id}/announcements/{annId}/update' => ['module' => 'org-announcements', 'action' => 'update'],
'organizations/{id}/announcements/{annId}/publish' => ['module' => 'org-announcements', 'action' => 'publish'], 'organizations/{id}/announcements/{annId}/publish' => ['module' => 'org-announcements', 'action' => 'publish'],
'organizations/{id}/announcements/{annId}/unpublish' => ['module' => 'org-announcements', 'action' => 'unpublish'],
'organizations/{id}/announcements/{annId}/delete' => ['module' => 'org-announcements', 'action' => 'delete'], 'organizations/{id}/announcements/{annId}/delete' => ['module' => 'org-announcements', 'action' => 'delete'],
]; ];
This diff is collapsed.
...@@ -10,7 +10,7 @@ $formAction = $isEdit ...@@ -10,7 +10,7 @@ $formAction = $isEdit
<a href="/organizations/<?= $org['id'] ?>/announcements" class="btn btn-icon btn-ghost"> <a href="/organizations/<?= $org['id'] ?>/announcements" class="btn btn-icon btn-ghost">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</a> </a>
<h1><?= $isEdit ? 'تعديل الإعلان' : 'إنشاء إعلان' ?> - <?= View::e($org['name_ar'] ?? $org['name']) ?></h1> <h1><?= $isEdit ? 'تعديل الإعلان' : 'إنشاء إعلان' ?> - <?= View::e(($org['name_ar'] ?? '') ?: $org['name']) ?></h1>
</div> </div>
</div> </div>
...@@ -21,29 +21,30 @@ $formAction = $isEdit ...@@ -21,29 +21,30 @@ $formAction = $isEdit
<div class="form-group"> <div class="form-group">
<label class="form-label">المنظمة</label> <label class="form-label">المنظمة</label>
<select name="org_id" class="form-input" disabled> <select name="org_id" class="form-input" disabled>
<option value="<?= $org['id'] ?>" selected><?= View::e($org['name_ar'] ?? $org['name']) ?></option> <option value="<?= $org['id'] ?>" selected><?= View::e(($org['name_ar'] ?? '') ?: $org['name']) ?></option>
</select> </select>
<input type="hidden" name="org_id" value="<?= $org['id'] ?>"> <input type="hidden" name="org_id" value="<?= $org['id'] ?>">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">العنوان (English) <span class="text-danger">*</span></label> <label class="form-label">العنوان (عربي) <span class="text-danger">*</span></label>
<input type="text" name="title" class="form-input" dir="ltr" value="<?= View::e($announcement['title'] ?? '') ?>" required> <input type="text" name="title_ar" class="form-input" maxlength="120" value="<?= View::e($announcement['title_ar'] ?? '') ?>" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">العنوان (عربي)</label> <label class="form-label">العنوان (English)</label>
<input type="text" name="title_ar" class="form-input" value="<?= View::e($announcement['title_ar'] ?? '') ?>"> <input type="text" name="title" class="form-input" dir="ltr" maxlength="120" value="<?= View::e(($announcement['title'] ?? '') !== ($announcement['title_ar'] ?? null) ? ($announcement['title'] ?? '') : '') ?>">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">المحتوى (English)</label> <label class="form-label">المحتوى (عربي) <span class="text-danger">*</span></label>
<textarea name="content" class="form-input" rows="5" dir="ltr"><?= View::e($announcement['content'] ?? '') ?></textarea> <textarea name="content_ar" class="form-input" rows="5" maxlength="4000" required><?= View::e($announcement['content_ar'] ?? '') ?></textarea>
<span class="form-hint">أول 140 حرفًا تصل للأعضاء في الإشعار عند النشر</span>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">المحتوى (عربي)</label> <label class="form-label">المحتوى (English)</label>
<textarea name="content_ar" class="form-input" rows="5"><?= View::e($announcement['content_ar'] ?? '') ?></textarea> <textarea name="content" class="form-input" rows="5" dir="ltr" maxlength="4000"><?= View::e(($announcement['content'] ?? '') !== ($announcement['content_ar'] ?? null) ? ($announcement['content'] ?? '') : '') ?></textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
...@@ -85,16 +86,28 @@ $formAction = $isEdit ...@@ -85,16 +86,28 @@ $formAction = $isEdit
<div class="mb-3"> <div class="mb-3">
<img src="<?= View::e($announcement['image_url']) ?>" alt="صورة الإعلان" style="max-width: 200px; border-radius: 8px;"> <img src="<?= View::e($announcement['image_url']) ?>" alt="صورة الإعلان" style="max-width: 200px; border-radius: 8px;">
</div> </div>
<label class="flex items-center gap-2 text-sm mb-2"><input type="checkbox" name="remove_image" value="1"> <span>إزالة الصورة الحالية</span></label>
<?php endif; ?> <?php endif; ?>
<input type="file" name="image" class="form-input" accept="image/*"> <input type="file" name="image" class="form-input" accept="image/jpeg,image/png,image/webp">
<span class="form-hint">الحد الأقصى: 10MB. الصيغ المدعومة: JPG, PNG, GIF, WebP</span> <span class="form-hint">الحد الأقصى: 5MB. الصيغ المدعومة: JPG, PNG, WebP</span>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">تاريخ انتهاء الإعلان</label> <label class="form-label">تاريخ انتهاء الإعلان</label>
<input type="datetime-local" name="expires_at" class="form-input" dir="ltr" value="<?= View::e(!empty($announcement['expires_at']) ? date('Y-m-d\TH:i', strtotime($announcement['expires_at'])) : '') ?>"> <input type="datetime-local" name="expires_at" class="form-input" dir="ltr" value="<?= View::e(Time::toInput($announcement['expires_at'] ?? null)) ?>">
<span class="form-hint">اتركه فارغاً لإعلان بدون انتهاء</span> <span class="form-hint">بتوقيت القاهرة. اتركه فارغاً لإعلان بدون انتهاء</span>
</div>
<?php if (!$isEdit && Auth::hasPermission('org-announcements.update')): ?>
<div class="form-group">
<label class="form-checkbox">
<input type="checkbox" name="publish_now" value="1">
<span class="checkbox-mark"></span>
<span class="checkbox-label">انشر الآن وأرسل إشعارًا لكل الأعضاء النشطين</span>
</label>
<span class="form-hint">بدونه يُحفظ الإعلان كمسودة لا يراها الأعضاء</span>
</div> </div>
<?php endif; ?>
<div class="flex gap-3 mt-6"> <div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
<a href="/organizations/<?= $org['id'] ?>" class="btn btn-icon btn-ghost"> <a href="/organizations/<?= $org['id'] ?>" class="btn btn-icon btn-ghost">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</a> </a>
<h1>الإعلانات - <?= View::e($org['name_ar'] ?? $org['name']) ?></h1> <h1>الإعلانات - <?= View::e(($org['name_ar'] ?? '') ?: $org['name']) ?></h1>
</div> </div>
<a href="/organizations/<?= $org['id'] ?>/announcements/create" class="btn btn-primary"> <a href="/organizations/<?= $org['id'] ?>/announcements/create" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
...@@ -11,6 +11,8 @@ ...@@ -11,6 +11,8 @@
</a> </a>
</div> </div>
<p class="text-sm text-muted mb-4">المنشور يظهر في صفحة المنظمة في اللعبة، ويصل إشعار واحد إلى كل عضو نشط (<?= (int)($activeMembers ?? 0) ?>) عند النشر الأول.</p>
<div class="card mb-5"> <div class="card mb-5">
<form method="GET" class="flex gap-4 items-end flex-wrap"> <form method="GET" class="flex gap-4 items-end flex-wrap">
<div class="form-group mb-0"> <div class="form-group mb-0">
...@@ -84,9 +86,9 @@ ...@@ -84,9 +86,9 @@
<?php foreach ($announcements as $ann): ?> <?php foreach ($announcements as $ann): ?>
<tr> <tr>
<td> <td>
<span class="fw-500"><?= View::e($ann['title_ar'] ?? $ann['title']) ?></span> <span class="fw-500"><?= View::e(($ann['title_ar'] ?? '') ?: $ann['title']) ?></span>
</td> </td>
<td><?= View::e($org['name_ar'] ?? $org['name']) ?></td> <td><?= View::e(($org['name_ar'] ?? '') ?: $org['name']) ?></td>
<td> <td>
<span class="badge <?= $typeBadges[$ann['type']] ?? 'badge-default' ?>"> <span class="badge <?= $typeBadges[$ann['type']] ?? 'badge-default' ?>">
<?= $typeLabels[$ann['type']] ?? $ann['type'] ?> <?= $typeLabels[$ann['type']] ?? $ann['type'] ?>
...@@ -108,7 +110,7 @@ ...@@ -108,7 +110,7 @@
<?php endif; ?> <?php endif; ?>
</td> </td>
<td class="tabular-nums"> <td class="tabular-nums">
<?= !empty($ann['published_at']) ? date('Y-m-d H:i', strtotime($ann['published_at'])) : '-' ?> <?= Time::show($ann['published_at'] ?? null) ?><?php if (!empty($ann['expires_at'])): ?><div class="text-xs text-muted">ينتهي <?= Time::show($ann['expires_at']) ?></div><?php endif; ?>
</td> </td>
<td><?= (int)($ann['view_count'] ?? 0) ?></td> <td><?= (int)($ann['view_count'] ?? 0) ?></td>
<td> <td>
...@@ -117,11 +119,14 @@ ...@@ -117,11 +119,14 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>
</button> </button>
<div class="dropdown-menu"> <div class="dropdown-menu">
<?php if (!Auth::hasPermission('org-announcements.update')): ?>
<span class="dropdown-item text-muted">النشر والتعديل لمدير المنظمة</span>
<?php else: ?>
<a href="/organizations/<?= $org['id'] ?>/announcements/<?= $ann['id'] ?>/edit" class="dropdown-item">تعديل</a> <a href="/organizations/<?= $org['id'] ?>/announcements/<?= $ann['id'] ?>/edit" class="dropdown-item">تعديل</a>
<?php if ($ann['is_draft'] ?? true): ?> <?php if ($ann['is_draft'] ?? true): ?>
<form method="POST" action="/organizations/<?= $org['id'] ?>/announcements/<?= $ann['id'] ?>/publish" style="margin:0;"> <form method="POST" action="/organizations/<?= $org['id'] ?>/announcements/<?= $ann['id'] ?>/publish" style="margin:0;">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>"> <input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button type="submit" class="dropdown-item">نشر</button> <button type="submit" class="dropdown-item" onclick="return confirm('<?= empty($ann['published_at']) ? 'نشر الإعلان وإرسال إشعار لكل الأعضاء النشطين؟' : 'إعادة نشر الإعلان؟ لن يُرسل إشعار جديد.' ?>')">نشر</button>
</form> </form>
<?php else: ?> <?php else: ?>
<form method="POST" action="/organizations/<?= $org['id'] ?>/announcements/<?= $ann['id'] ?>/unpublish" style="margin:0;"> <form method="POST" action="/organizations/<?= $org['id'] ?>/announcements/<?= $ann['id'] ?>/unpublish" style="margin:0;">
...@@ -130,7 +135,8 @@ ...@@ -130,7 +135,8 @@
</form> </form>
<?php endif; ?> <?php endif; ?>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<button class="dropdown-item danger" onclick="confirmDelete('/organizations/<?= $org['id'] ?>/announcements/<?= $ann['id'] ?>/delete', '<?= View::e($ann['title_ar'] ?? $ann['title']) ?>')">حذف</button> <button class="dropdown-item danger" onclick="confirmDelete('/organizations/<?= $org['id'] ?>/announcements/<?= $ann['id'] ?>/delete', '<?= View::e(($ann['title_ar'] ?? '') ?: $ann['title']) ?>')">حذف</button>
<?php endif; ?>
</div> </div>
</div> </div>
</td> </td>
...@@ -143,7 +149,7 @@ ...@@ -143,7 +149,7 @@
<span><?= $pagination->rangeText() ?></span> <span><?= $pagination->rangeText() ?></span>
<div class="pagination"> <div class="pagination">
<?php foreach ($pagination->pages() as $p): ?> <?php foreach ($pagination->pages() as $p): ?>
<a href="?page=<?= $p ?>&type=<?= $_GET['type'] ?? '' ?>&is_draft=<?= $_GET['is_draft'] ?? '' ?>" class="pagination-btn <?= $p === $pagination->page ? 'active' : '' ?>"><?= $p ?></a> <a href="?page=<?= $p ?>&type=<?= View::e($_GET['type'] ?? '') ?>&is_draft=<?= View::e($_GET['is_draft'] ?? '') ?>" class="pagination-btn <?= $p === $pagination->page ? 'active' : '' ?>"><?= $p ?></a>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
</div> </div>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment