Commit 8b31a26d authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: profile edit + org application with proof upload

Players can now fill all profile fields (bio, country, city, FIDE info)
from their profile tab and apply for organizations with document proof.
Also fixes avatar.php to use SERVICE_KEY for storage uploads.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent ed1fc3ce
...@@ -73,7 +73,7 @@ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); ...@@ -73,7 +73,7 @@ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [ curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . SUPABASE_SERVICE_KEY, 'Authorization: Bearer ' . SUPABASE_SERVICE_KEY,
'apikey: ' . SUPABASE_ANON_KEY, 'apikey: ' . SUPABASE_SERVICE_KEY,
'Content-Type: ' . $mime, 'Content-Type: ' . $mime,
'x-upsert: true' 'x-upsert: true'
]); ]);
......
<?php
error_reporting(E_ALL);
ini_set('display_errors', 0);
set_exception_handler(function($e) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(['error' => $e->getMessage(), 'line' => $e->getLine()]);
exit;
});
set_error_handler(function($errno, $errstr, $errfile, $errline) {
throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
});
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed']); exit; }
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/supabase.php';
$token = requireAuth();
$userId = getUserId($token);
if (!$userId) jsonError('Invalid user', 401);
// Validate required fields
$orgId = $_POST['org_id'] ?? null;
$documentType = $_POST['document_type'] ?? null;
$notes = $_POST['notes'] ?? '';
if (!$orgId) jsonError('org_id is required');
if (!$documentType) jsonError('document_type is required');
// Validate document_type
$validTypes = ['membership_card', 'id_card', 'receipt', 'other'];
if (!in_array($documentType, $validTypes)) {
jsonError('Invalid document_type. Allowed: ' . implode(', ', $validTypes));
}
// Check if already a member
$db = supabaseService();
$existing = $db->get('org_memberships', [
'user_id' => 'eq.' . $userId,
'organization_id' => 'eq.' . $orgId,
'status' => 'eq.active',
'limit' => 1
]);
if (is_array($existing) && !isset($existing['error']) && !empty($existing)) {
jsonError('Already a member of this organization');
}
// Check if pending application exists
$pending = $db->get('org_membership_applications', [
'player_id' => 'eq.' . $userId,
'org_id' => 'eq.' . $orgId,
'status' => 'eq.pending',
'limit' => 1
]);
if (is_array($pending) && !isset($pending['error']) && !empty($pending)) {
jsonError('You already have a pending application for this organization');
}
// Handle file upload
$documentUrl = null;
if (isset($_FILES['proof_document'])) {
if ($_FILES['proof_document']['error'] !== UPLOAD_ERR_OK) {
$errors = [1 => 'File exceeds server limit', 2 => 'File exceeds form limit', 3 => 'Partial upload', 4 => 'No file sent', 6 => 'No tmp dir', 7 => 'Write failed'];
jsonError($errors[$_FILES['proof_document']['error']] ?? 'Upload error code ' . $_FILES['proof_document']['error']);
}
$file = $_FILES['proof_document'];
if ($file['size'] > 5 * 1024 * 1024) {
jsonError('File too large (max 5MB)');
}
$mime = mime_content_type($file['tmp_name']);
$allowed = ['image/jpeg', 'image/png', 'image/webp'];
if (!in_array($mime, $allowed)) {
jsonError('Invalid file type: ' . $mime . '. Allowed: JPEG, PNG, WebP');
}
$ext = match($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
default => 'jpg'
};
$filename = $userId . '_' . time() . '_' . bin2hex(random_bytes(4)) . '.' . $ext;
$storagePath = 'org-proofs/' . $userId . '/' . $filename;
$bucket = 'assets';
$storageUrl = SUPABASE_STORAGE . '/object/' . $bucket . '/' . $storagePath;
$fileContents = file_get_contents($file['tmp_name']);
if ($fileContents === false) {
jsonError('Failed to read uploaded file');
}
$ch = curl_init($storageUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . SUPABASE_SERVICE_KEY,
'apikey: ' . SUPABASE_SERVICE_KEY,
'Content-Type: ' . $mime,
'x-upsert: true'
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, $fileContents);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
jsonError('Storage connection failed: ' . $curlError, 500);
}
if ($httpCode >= 400) {
$decoded = json_decode($response, true);
jsonError('Storage error (' . $httpCode . '): ' . ($decoded['message'] ?? $decoded['error'] ?? $response), 500);
}
$documentUrl = SUPABASE_STORAGE . '/object/public/' . $bucket . '/' . $storagePath . '?apikey=' . SUPABASE_ANON_KEY;
}
// Insert application
$appData = [
'org_id' => $orgId,
'player_id' => $userId,
'status' => 'pending',
'document_type' => $documentType,
'notes' => $notes
];
if ($documentUrl) {
$appData['document_url'] = $documentUrl;
}
$result = $db->insert('org_membership_applications', $appData);
if (isset($result['error'])) {
jsonError('Failed to submit application: ' . $result['error']);
}
jsonResponse([
'success' => true,
'application_id' => $result[0]['id'] ?? null,
'document_url' => $documentUrl
]);
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
header('Content-Type: application/json'); header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, PATCH, OPTIONS'); header('Access-Control-Allow-Methods: GET, POST, PATCH, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization'); header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; } if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
...@@ -35,11 +35,39 @@ if ($method === 'GET') { ...@@ -35,11 +35,39 @@ if ($method === 'GET') {
if ($method === 'PATCH') { if ($method === 'PATCH') {
$input = getInput(); $input = getInput();
$allowed = ['display_name', 'display_name_ar', 'avatar_url', 'frame_id', 'border_color', 'country_code']; $allowed = [
'display_name', 'display_name_ar', 'avatar_url', 'frame_id', 'border_color',
'bio', 'bio_ar', 'country_code', 'city', 'preferred_language',
'fide_id', 'fide_rating_standard', 'fide_rating_rapid', 'fide_rating_blitz', 'fide_title'
];
$data = array_intersect_key($input, array_flip($allowed)); $data = array_intersect_key($input, array_flip($allowed));
if (empty($data)) jsonError('No valid fields to update'); if (empty($data)) jsonError('No valid fields to update');
// Validate fide_title if provided
if (isset($data['fide_title']) && $data['fide_title'] !== null && $data['fide_title'] !== '') {
$validTitles = ['GM', 'IM', 'FM', 'CM', 'WGM', 'WIM', 'WFM', 'WCM'];
if (!in_array($data['fide_title'], $validTitles)) {
jsonError('Invalid FIDE title');
}
}
// Validate numeric fields
$numericFields = ['fide_rating_standard', 'fide_rating_rapid', 'fide_rating_blitz'];
foreach ($numericFields as $field) {
if (isset($data[$field]) && $data[$field] !== null && $data[$field] !== '') {
$data[$field] = (int)$data[$field];
if ($data[$field] < 0 || $data[$field] > 4000) {
jsonError("Invalid $field value");
}
}
}
// Validate preferred_language
if (isset($data['preferred_language']) && !in_array($data['preferred_language'], ['ar', 'en'])) {
jsonError('Invalid preferred_language');
}
$db = supabase($token); $db = supabase($token);
$result = $db->update('profiles', $data, ['id' => 'eq.' . $userId]); $result = $db->update('profiles', $data, ['id' => 'eq.' . $userId]);
...@@ -50,4 +78,91 @@ if ($method === 'PATCH') { ...@@ -50,4 +78,91 @@ if ($method === 'PATCH') {
jsonResponse($result[0] ?? ['success' => true]); jsonResponse($result[0] ?? ['success' => true]);
} }
if ($method === 'POST') {
$input = getInput();
$action = $input['action'] ?? '';
if ($action === 'get-orgs') {
$db = supabaseService();
$orgs = $db->get('organizations', ['select' => 'id,name,slug,logo_url,country_code', 'order' => 'name.asc']);
if (isset($orgs['error'])) jsonError($orgs['error']);
jsonResponse($orgs);
}
if ($action === 'my-memberships') {
$db = supabase($token);
$memberships = $db->get('org_memberships', [
'user_id' => 'eq.' . $userId,
'select' => 'id,organization_id,role,status,joined_at,organizations(id,name,slug,logo_url)'
]);
if (isset($memberships['error'])) jsonError($memberships['error']);
jsonResponse(is_array($memberships) ? $memberships : []);
}
if ($action === 'my-applications') {
$db = supabase($token);
$apps = $db->get('org_membership_applications', [
'player_id' => 'eq.' . $userId,
'select' => 'id,org_id,status,document_type,notes,rejection_reason,created_at,organizations(id,name,slug,logo_url)',
'order' => 'created_at.desc',
'limit' => 50
]);
if (isset($apps['error'])) jsonError($apps['error']);
jsonResponse(is_array($apps) ? $apps : []);
}
if ($action === 'apply-org') {
$orgId = $input['org_id'] ?? null;
$documentType = $input['document_type'] ?? null;
$notes = $input['notes'] ?? '';
$documentUrl = $input['document_url'] ?? null;
if (!$orgId || !$documentType) {
jsonError('org_id and document_type are required');
}
// Check if already a member
$db = supabase($token);
$existing = $db->get('org_memberships', [
'user_id' => 'eq.' . $userId,
'organization_id' => 'eq.' . $orgId,
'status' => 'eq.active',
'limit' => 1
]);
if (is_array($existing) && !isset($existing['error']) && !empty($existing)) {
jsonError('Already a member of this organization');
}
// Check if pending application exists
$pending = $db->get('org_membership_applications', [
'player_id' => 'eq.' . $userId,
'org_id' => 'eq.' . $orgId,
'status' => 'eq.pending',
'limit' => 1
]);
if (is_array($pending) && !isset($pending['error']) && !empty($pending)) {
jsonError('You already have a pending application for this organization');
}
$appData = [
'org_id' => $orgId,
'player_id' => $userId,
'status' => 'pending',
'document_type' => $documentType,
'notes' => $notes
];
if ($documentUrl) {
$appData['document_url'] = $documentUrl;
}
$dbService = supabaseService();
$result = $dbService->insert('org_membership_applications', $appData);
if (isset($result['error'])) jsonError($result['error']);
jsonResponse($result[0] ?? ['success' => true]);
}
jsonError('Invalid action');
}
jsonError('Method not allowed', 405); jsonError('Method not allowed', 405);
import * as scene from '../../core/scene.js'; import * as scene from '../../core/scene.js';
import { mountView } from './scenes/view.js'; import { mountView } from './scenes/view.js';
import { mountSettings } from './scenes/settings.js'; import { mountSettings } from './scenes/settings.js';
import { mountEdit } from './scenes/edit.js';
import { mountOrgApply } from './scenes/org-apply.js';
scene.register('profile-view', mountView); scene.register('profile-view', mountView);
scene.register('profile-settings', mountSettings); scene.register('profile-settings', mountSettings);
scene.register('profile-edit', mountEdit);
scene.register('profile-org-apply', mountOrgApply);
This diff is collapsed.
This diff is collapsed.
...@@ -58,6 +58,18 @@ export async function mountView(el) { ...@@ -58,6 +58,18 @@ export async function mountView(el) {
</div> </div>
</div> </div>
<!-- Organization Membership -->
<div class="card" id="org-section">
<div style="font-size:15px;font-weight:600;margin-bottom:var(--s-3);">${emoji('building', '🏢', 18)} المنظمة</div>
<div id="org-membership-content" style="font-size:13px;color:var(--text-secondary);">جاري التحميل...</div>
</div>
<!-- Profile Actions -->
<div style="display:flex;flex-direction:column;gap:var(--s-3);">
<button class="btn btn-primary w-full" id="btn-edit-profile" style="min-height:44px;">${emoji('edit', '✏️', 16)} تعديل الملف الشخصي</button>
<button class="btn btn-secondary w-full" id="btn-org-apply" style="min-height:44px;">${emoji('building', '🏢', 16)} الانضمام لمنظمة</button>
</div>
<!-- Actions --> <!-- Actions -->
<div style="display:flex;gap:var(--s-3);"> <div style="display:flex;gap:var(--s-3);">
<button class="btn btn-secondary w-full" id="btn-settings">${t('profile.settings')}</button> <button class="btn btn-secondary w-full" id="btn-settings">${t('profile.settings')}</button>
...@@ -110,6 +122,16 @@ export async function mountView(el) { ...@@ -110,6 +122,16 @@ export async function mountView(el) {
uploading.style.display = 'none'; uploading.style.display = 'none';
}); });
el.querySelector('#btn-edit-profile').addEventListener('click', () => {
audio.play('click');
scene.push('profile-edit');
});
el.querySelector('#btn-org-apply').addEventListener('click', () => {
audio.play('click');
scene.push('profile-org-apply');
});
el.querySelector('#btn-settings').addEventListener('click', () => { el.querySelector('#btn-settings').addEventListener('click', () => {
audio.play('click'); audio.play('click');
scene.push('profile-settings'); scene.push('profile-settings');
...@@ -119,6 +141,42 @@ export async function mountView(el) { ...@@ -119,6 +141,42 @@ export async function mountView(el) {
audio.play('click'); audio.play('click');
bus.emit('auth:logout'); bus.emit('auth:logout');
}); });
// Load org membership
loadOrgMembership(el);
}
async function loadOrgMembership(el) {
const content = el.querySelector('#org-membership-content');
if (!content) return;
try {
const memberships = await net.post('profile.php', { action: 'my-memberships' });
if (Array.isArray(memberships) && memberships.length > 0) {
const active = memberships.filter(m => m.status === 'active');
if (active.length > 0) {
content.innerHTML = active.map(m => {
const org = m.organizations;
const logoHtml = org && org.logo_url
? `<img src="${org.logo_url}" style="width:28px;height:28px;border-radius:6px;object-fit:contain;" alt="">`
: `<div style="width:28px;height:28px;border-radius:6px;background:var(--bg-elevated);display:flex;align-items:center;justify-content:center;">${emoji('building', '🏢', 14)}</div>`;
return `
<div style="display:flex;align-items:center;gap:var(--s-2);padding:var(--s-2) 0;">
${logoHtml}
<div style="flex:1;">
<div style="font-size:13px;font-weight:600;color:var(--text-primary);">${org ? org.name : 'منظمة'}</div>
<div style="font-size:11px;color:var(--text-secondary);">${m.role || 'عضو'}</div>
</div>
<span style="background:var(--success);color:#fff;font-size:10px;padding:2px 6px;border-radius:10px;">عضو</span>
</div>
`;
}).join('');
return;
}
}
content.innerHTML = `<div style="font-size:13px;color:var(--text-secondary);">لست عضواً في أي منظمة بعد</div>`;
} catch (e) {
content.innerHTML = `<div style="font-size:13px;color:var(--text-secondary);">لست عضواً في أي منظمة بعد</div>`;
}
} }
function compressAvatar(file) { function compressAvatar(file) {
......
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