11 Commits
Author SHA1 Message Date
Amirmahdi 3f3b5b1d69 fix 2026-08-20 18:38:04 +03:30
Amirmahdi f505180237 fix 2026-08-20 18:19:37 +03:30
Amirmahdi ff946959a5 fix 2026-08-20 18:09:11 +03:30
Amirmahdi b2da21e99c fix 2026-08-20 17:02:25 +03:30
Amirmahdi 5c6a771d7a fix 2026-08-20 16:51:07 +03:30
Amirmahdi ffdae46038 feat: add survey 2026-08-20 15:44:56 +03:30
Amirmahdi 27413ae827 feat: add is_premium 2026-07-01 12:31:45 +03:30
Amirmahdi b97ce2dcef fix: last version fix 2026-06-30 13:40:40 +03:30
Amirmahdi 7b5c23c3e1 fix: description not null 2026-06-30 13:21:33 +03:30
Amirmahdi d50e134b1c Merge branch 'main' of http://git.approagency.ir/Amirmahdi/back-meditation 2026-06-30 12:59:17 +03:30
Amirmahdi 094f02e460 feat: add link url slider 2026-06-30 12:58:54 +03:30
11 changed files with 299 additions and 14 deletions
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
class SyncSurveyUsers extends Command
{
protected $signature = 'survey:sync-users {--token= : Approagency admin token}';
protected $description = 'Sync user profile fields (name, age, gender) from approagency for all meditation users with missing data';
public function handle()
{
$token = $this->option('token') ?: config('services.approagency.token');
if (empty($token)) {
$this->error('Provide --token=<approagency_admin_token> or set APPROAGENCY_ADMIN_TOKEN in .env');
return 1;
}
$users = User::whereNull('first_name')
->whereNotNull('mobile')
->get();
$this->info("Found {$users->count()} users with missing first_name. Syncing...");
$synced = 0;
$failed = 0;
foreach ($users as $user) {
$retries = 0;
while ($retries <= 3) {
try {
$response = Http::withToken($token)
->retry(2, 2000)
->get('https://api.approagency.ir/api/admin/users', [
'mobile' => $user->mobile,
'per_page' => 1,
]);
if ($response->status() === 429) {
$retries++;
$wait = $retries * 3;
$this->warn(" rate limited on {$user->mobile}, waiting {$wait}s...");
sleep($wait);
continue;
}
if ($response->successful()) {
$data = $response->json('data.0');
if ($data) {
$user->update([
'first_name' => $data['first_name'] ?? $user->first_name,
'last_name' => $data['last_name'] ?? $user->last_name,
'age' => $data['age'] ?? $user->age,
'gender' => $data['gender'] ?? $user->gender,
'birthday' => $data['birthday'] ?? $user->birthday,
'identifier' => $data['uuid'] ?? $user->identifier,
]);
$name = $data['first_name'] ?? 'null';
$this->line(" synced: {$user->mobile}{$name}");
$synced++;
}
} else {
$this->warn(" skip: {$user->mobile} (HTTP {$response->status()})");
$failed++;
}
break;
} catch (\Exception $e) {
$retries++;
if ($retries > 3) {
$this->error(" failed: {$user->mobile}{$e->getMessage()}");
$failed++;
} else {
sleep($retries * 2);
}
}
}
sleep(3); // 3s between requests
}
$this->info("Done. Synced: {$synced}, Failed: {$failed}");
return 0;
}
}
@@ -92,6 +92,8 @@ public function getTemplates()
} else {
$template->image_url = null;
}
// Old app expects a non-null description string.
$template->description = $template->description ?? '';
return $template;
});
@@ -109,6 +111,8 @@ public function getUserTemplates()
} else {
$template->image_url = null;
}
// Old app expects a non-null description string.
$template->description = $template->description ?? '';
return $template;
});
+8 -1
View File
@@ -104,7 +104,14 @@ public function index(Request $request)
$query->limit(max(1, (int) $request->input('count')));
}
return response()->json($query->get());
$media = $query->get();
// Old app expects a non-null external_url; fall back to the playable url.
$media->each(function ($m) {
$m->external_url = $m->external_url ?: ($m->url ?? '');
});
return response()->json($media);
}
// Apply the category / subcategory / duration / tag / search filters shared by
@@ -50,6 +50,7 @@ public function store(Request $request)
'detail_image' => 'nullable|file|extensions:jpg,jpeg,png,gif,webp,bmp,svg|max:8192',
'order' => 'nullable|integer',
'is_active' => 'nullable|boolean',
'is_premium' => 'nullable|boolean',
]);
// Ensure at least one category or subcategory is provided
@@ -144,6 +145,7 @@ public function update(Request $request, $id)
'detail_image' => 'nullable|file|extensions:jpg,jpeg,png,gif,webp,bmp,svg|max:8192',
'order' => 'nullable|integer',
'is_active' => 'nullable|boolean',
'is_premium' => 'nullable|boolean',
]);
if (isset($data['name'])) {
+34 -4
View File
@@ -13,10 +13,40 @@ class SliderController extends Controller
public function index()
{
return response()->json([
'data' => Slider::with('image')->get()
'data' => Slider::with('image')->get()->map(fn ($s) => $this->formatSlider($s))->values(),
]);
}
// Shape a slider so the (old) app parses it safely: description is always a
// string, and action.type is normalized to 'navigation' or 'link' (the only
// types the client understands) — unknown/empty actions become null.
private function formatSlider(Slider $slider): array
{
return array_merge($slider->toArray(), [
'description' => $slider->description ?? '',
'action' => $this->normalizeAction($slider->action),
]);
}
private function normalizeAction($action): ?array
{
if (!is_array($action) || empty($action['type'])) {
return null;
}
$type = $action['type'];
if (in_array($type, ['navigation', 'screen'], true)) {
return ['type' => 'navigation', 'path' => $action['path'] ?? ($action['url'] ?? '')];
}
if (in_array($type, ['link', 'url'], true)) {
return ['type' => 'link', 'url' => $action['url'] ?? ($action['path'] ?? '')];
}
return null; // unknown type → drop so the client doesn't throw
}
public function store(Request $request)
{
$data = $request->validate([
@@ -35,14 +65,14 @@ public function store(Request $request)
return response()->json([
'message' => 'Slider created successfully',
'data' => $slider->load('image'),
'data' => $this->formatSlider($slider->load('image')),
]);
}
public function show($id)
{
$slider = Slider::with('image')->findOrFail($id);
return response()->json($slider);
return response()->json($this->formatSlider($slider));
}
public function update(Request $request, $id)
@@ -67,7 +97,7 @@ public function update(Request $request, $id)
return response()->json([
'message' => 'Slider updated successfully',
'data' => $slider->load('image'),
'data' => $this->formatSlider($slider->load('image')),
]);
}
@@ -6,8 +6,10 @@
use App\Models\SurveyAnswer;
use App\Models\SurveyQuestion;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
class SurveyQuestionController extends Controller
@@ -56,6 +58,104 @@ public function adminShow($id)
return response()->json($question);
}
// ADMIN: paginated list of users with their survey stats (age, gender, answers count).
public function adminUsers(Request $request)
{
$perPage = max(1, min((int) $request->input('per_page', 20), 100));
$answered = $request->input('answered');
$query = User::select('id', 'first_name', 'last_name', 'age', 'gender', 'email', 'mobile', 'created_at', 'identifier')
->withCount(['surveyAnswers as answers_count'])
->withCount(['surveyAnswers as questions_answered' => function ($q) {
$q->select(DB::raw('COUNT(DISTINCT survey_question_id)'));
}])
->orderByDesc('created_at');
if ($answered === '1') {
$query->has('surveyAnswers');
} elseif ($answered === '0') {
$query->doesntHave('surveyAnswers');
}
$users = $query->paginate($perPage);
return response()->json($users);
}
// ADMIN: single user profile with all their survey answers grouped by question.
// Accepts either local user ID or approagency identifier (UUID).
public function adminUserShow($identifier)
{
$query = User::query()
->select('id', 'first_name', 'last_name', 'age', 'gender', 'birthday', 'email', 'mobile', 'created_at')
->withCount(['surveyAnswers as answers_count'])
->withCount(['surveyAnswers as questions_answered' => function ($q) {
$q->select(DB::raw('COUNT(DISTINCT survey_question_id)'));
}]);
if (is_numeric($identifier)) {
$query->where('id', $identifier);
} else {
$query->where('identifier', $identifier);
}
$user = $query->first();
if (!$user) {
abort(404);
}
$answers = SurveyAnswer::where('user_id', $user->id)
->with(['question.options', 'option'])
->get()
->groupBy('survey_question_id')
->map(function ($group) {
$question = $group->first()->question;
return [
'question_id' => $question->id,
'question' => $question->question,
'type' => $question->type,
'answers' => $group->map(fn ($a) => [
'option_id' => $a->survey_option_id,
'label' => $a->option->label,
])->values(),
];
})
->values();
$user->survey_answers = $answers;
return response()->json($user);
}
// ADMIN: update a user's profile fields from approagency data.
public function adminUserUpdate(Request $request, $identifier)
{
if (is_numeric($identifier)) {
$user = User::where('id', $identifier)->first();
} else {
$user = User::where('identifier', $identifier)->first();
}
if (!$user) {
abort(404);
}
$data = $request->validate([
'first_name' => 'nullable|string',
'last_name' => 'nullable|string',
'age' => 'nullable|integer',
'gender' => 'nullable|integer',
'birthday' => 'nullable|string',
'identifier' => 'nullable|string',
'email' => 'nullable|email',
]);
$user->update(array_filter($data, fn ($v) => !is_null($v)));
return response()->json($user);
}
// ADMIN: clean aggregated results — per option vote counts & percentages, no raw rows.
// Pass an $id for one question, omit it for all questions.
public function adminAnalytics($id = null)
+29 -7
View File
@@ -66,21 +66,31 @@ public function loginV2(Request $request)
'email' => $response['email'],
'identifier' => $response['uuid'] ?? null,
'password' => config('app.default_password'),
// 'name' => trim(($response['first_name'] ?? '') . ' ' . ($response['last_name'] ?? '')),
'mobile' => $mobile,
'first_name' => $response['first_name'] ?? null,
'last_name' => $response['last_name'] ?? null,
'age' => $response['age'] ?? null,
'gender' => $response['gender'] ?? null,
'birthday' => $response['birthday'] ?? null,
'referral_code' => $response['referral_code'] ?? null,
]);
} else {
// Only update name and mobile for existing user
// Keep the local identifier aligned with the current approagency uuid
// (e.g. when the user was matched by mobile, not identifier).
$user->identifier = $response['uuid'] ?? $user->identifier;
$user->first_name = $response['first_name'] ?? $user->first_name;
$user->last_name = $response['last_name'] ?? $user->last_name;
// $user->name = trim(($response['first_name'] ?? '') . ' ' . ($response['last_name'] ?? ''));
// Sync names — update if the response has a non-null, non-empty value
if (!empty($response['first_name'])) {
$user->first_name = $response['first_name'];
}
if (!empty($response['last_name'])) {
$user->last_name = $response['last_name'];
}
$user->mobile = $mobile ?? $user->mobile;
$user->age = $response['age'] ?? $user->age;
$user->gender = $response['gender'] ?? $user->gender;
$user->birthday = $response['birthday'] ?? $user->birthday;
$user->referral_code = $response['referral_code'] ?? $user->referral_code;
$user->save();
}
@@ -134,15 +144,27 @@ private function syncUserFromStatus(array $response, ?User $user = null): User
'mobile' => $mobile,
'first_name' => $response['first_name'] ?? null,
'last_name' => $response['last_name'] ?? null,
'age' => $response['age'] ?? null,
'gender' => $response['gender'] ?? null,
'birthday' => $response['birthday'] ?? null,
'referral_code' => $response['referral_code'] ?? null,
'email_verified_at' => $response['email_verified_at'] ?? null
]);
} else {
// update existing
$user->identifier = $response['uuid'] ?? $user->identifier;
$user->first_name = $response['first_name'] ?? $user->first_name;
$user->last_name = $response['last_name'] ?? $user->last_name;
if (!empty($response['first_name'])) {
$user->first_name = $response['first_name'];
}
if (!empty($response['last_name'])) {
$user->last_name = $response['last_name'];
}
$user->mobile = $mobile ?? $user->mobile;
$user->age = $response['age'] ?? $user->age;
$user->gender = $response['gender'] ?? $user->gender;
$user->birthday = $response['birthday'] ?? $user->birthday;
$user->referral_code = $response['referral_code'] ?? $user->referral_code;
$user->email_verified_at = $response['email_verified_at'] ?? $user->email_verified_at;
$user->save();
+2 -1
View File
@@ -16,11 +16,12 @@ class MusicPlaylist extends Model
protected $table = 'music_playlists';
protected $fillable = [
'name', 'slug', 'description', 'image_id', 'detail_image_id', 'order', 'is_active'
'name', 'slug', 'description', 'image_id', 'detail_image_id', 'order', 'is_active', 'is_premium'
];
protected $casts = [
'is_active' => 'boolean',
'is_premium' => 'boolean',
'order' => 'integer',
];
protected $appends = [
+5
View File
@@ -143,4 +143,9 @@ public function savedMedia()
return $this->belongsToMany(Media::class, 'saved_media')->withTimestamps();
}
public function surveyAnswers()
{
return $this->hasMany(SurveyAnswer::class);
}
}
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('music_playlists', function (Blueprint $table) {
$table->boolean('is_premium')->default(false)->after('is_active');
});
}
public function down(): void
{
Schema::table('music_playlists', function (Blueprint $table) {
$table->dropColumn('is_premium');
});
}
};
+4
View File
@@ -166,6 +166,10 @@
Route::get('/admin/survey-questions/{id}/analytics', [SurveyQuestionController::class, 'adminAnalytics']);
Route::get('/admin/survey-questions', [SurveyQuestionController::class, 'adminIndex']);
Route::get('/admin/survey-questions/{id}', [SurveyQuestionController::class, 'adminShow']);
// User list + detail for survey admin
Route::get('/admin/survey-users', [SurveyQuestionController::class, 'adminUsers']);
Route::get('/admin/survey-users/{id}', [SurveyQuestionController::class, 'adminUserShow']);
Route::patch('/admin/survey-users/{id}', [SurveyQuestionController::class, 'adminUserUpdate']);
});
// User: answer + read questions with their own answers only.
Route::get('/survey-questions/suggested-media', [SurveyQuestionController::class, 'suggestedMedia']);