You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

746 lines
28 KiB

5 months ago
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Api\Concerns\ResolvesParticipantApplication;
use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\Competition;
1 month ago
use App\Models\CompetitionPortalLocale;
use App\Models\SignupChannel;
use App\Models\User;
use App\Support\ChannelCallbackUrlBuilder;
1 month ago
use App\Support\PortalLocale;
2 months ago
use App\Support\SignupApplicationFieldRegistry;
1 month ago
use App\Support\SignupDisplay;
4 months ago
use App\Support\SignupFormFileRules;
1 month ago
use App\Support\SignupOptionCatalog;
5 months ago
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
1 month ago
use Illuminate\Support\Facades\Schema;
5 months ago
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Throwable;
5 months ago
class ApplicationController extends Controller
{
use ResolvesParticipantApplication;
private const USER_PREFILL_SOURCES = [
'user.name' => 'name',
'user.mobile' => 'mobile',
'user.email' => 'email',
'user.company' => 'company',
];
5 months ago
private function ensureParticipantMayEditSignup(Application $app): void
{
$app->assertMayEditSignup('status');
}
private function currentApplication(Request $request): Application
{
return $this->participantApplication($request);
}
/**
* @return list<string>
*/
private function enabledTrackCodes(Competition $competition): array
{
return $competition->tracks()
->where('is_enabled', true)
->pluck('track_code')
->all();
}
1 month ago
/**
* @return list<string>
*/
private function allowedTrackCodes(Competition $competition): array
{
return array_values(array_unique([
...$this->enabledTrackCodes($competition),
SignupDisplay::TRACK_OTHER_CODE,
]));
}
5 months ago
/** 报名表 schema 是否包含参赛承诺书勾选(与选手端 key 一致) */
private function signupSchemaRequiresCommitment(Competition $competition): bool
{
$competition->loadMissing('formSchema');
$rows = $competition->formSchema?->schema_json;
if (! is_array($rows) || count($rows) === 0) {
return true;
}
foreach ($rows as $row) {
if (is_array($row) && ($row['key'] ?? '') === 'commitment_accepted') {
return true;
}
}
return false;
}
5 months ago
/** 当前赛事报名表 schema 是否包含指定字段 key */
private function signupSchemaHasKey(Competition $competition, string $key): bool
{
2 months ago
return SignupApplicationFieldRegistry::schemaHasKey($competition, $key);
5 months ago
}
/**
* @return list<string>
*/
1 month ago
private function selectValuesFromSignupSchema(Competition $competition, string $key): array
5 months ago
{
$competition->loadMissing('formSchema');
$rows = $competition->formSchema?->schema_json;
if (! is_array($rows)) {
return [];
}
foreach ($rows as $row) {
1 month ago
if (! is_array($row) || ($row['key'] ?? '') !== $key) {
5 months ago
continue;
}
$opts = $row['options'] ?? [];
if (! is_array($opts)) {
return [];
}
$out = [];
foreach ($opts as $opt) {
if (is_array($opt) && array_key_exists('value', $opt)) {
$s = trim((string) $opt['value']);
if ($s !== '') {
$out[] = $s;
}
} elseif (is_string($opt)) {
$s = trim($opt);
if ($s !== '') {
$out[] = $s;
}
}
}
return array_values(array_unique($out));
}
return [];
}
/**
* @return list<string>
*/
1 month ago
private function eventLocationOptionValues(): array
5 months ago
{
1 month ago
$raw = config('contest.event_locations', ['上海', '苏州', '深圳']);
if (! is_array($raw) || count($raw) === 0) {
return ['上海', '苏州', '深圳'];
5 months ago
}
1 month ago
return array_values(array_filter(array_map('strval', $raw), fn (string $v) => $v !== ''));
5 months ago
}
/**
* @return list<string>
*/
1 month ago
private function allowedEventLocationValues(Competition $competition): array
5 months ago
{
1 month ago
$fromSchema = $this->selectValuesFromSignupSchema($competition, 'event_location');
5 months ago
1 month ago
return array_values(array_unique([
...$fromSchema,
...SignupOptionCatalog::acceptedValues(SignupOptionCatalog::eventLocations(), 'event_location'),
]));
1 month ago
}
/**
* schema 中某字段是否标记为必填。
*/
private function signupSchemaFieldRequired(Competition $competition, string $key): bool
{
$competition->loadMissing('formSchema');
$rows = $competition->formSchema?->schema_json;
if (! is_array($rows)) {
return false;
}
foreach ($rows as $row) {
if (is_array($row) && ($row['key'] ?? '') === $key) {
return (bool) ($row['required'] ?? false);
}
}
return false;
5 months ago
}
5 months ago
public function show(Request $request): JsonResponse
{
$app = $this->currentApplication($request);
$app->load(['files']);
return response()->json($this->transform($app));
}
public function update(Request $request): JsonResponse
{
$competition = $this->resolvePublishedCompetitionFromRequest($request);
$app = $this->currentApplication($request);
$this->ensureParticipantMayEditSignup($app);
1 month ago
$this->normalizeSignupOptionInputs($request);
$trackCodes = $this->allowedTrackCodes($competition);
$degrees = SignupOptionCatalog::acceptedValues(SignupOptionCatalog::degrees(), 'degree');
$countries = SignupOptionCatalog::acceptedValues(SignupOptionCatalog::locationCountries(), 'location_country');
5 months ago
5 months ago
$companyRules = ['nullable', 'string', 'max:255'];
1 month ago
$trackRules = ['nullable', 'string', Rule::in($trackCodes)];
5 months ago
5 months ago
$rules = [
'player_name' => ['nullable', 'string', 'max:120'],
'school' => ['nullable', 'string', 'max:200'],
'degree' => ['nullable', 'string', Rule::in($degrees)],
'contact_email' => ['nullable', 'email', 'max:255'],
'contact_mobile' => ['nullable', 'regex:/^1[3-9]\d{9}$/'],
5 months ago
'company_name' => $companyRules,
5 months ago
'project_name' => ['nullable', 'string', 'max:255'],
'track' => $trackRules,
1 month ago
'track_other' => ['nullable', 'string', 'max:255'],
5 months ago
'location_country' => ['nullable', 'string', Rule::in($countries)],
'location_province' => ['nullable', 'string', 'max:100'],
'location_city' => ['nullable', 'string', 'max:100'],
'oversea_country' => ['nullable', 'string', 'max:100'],
'intro' => ['nullable', 'string', 'max:5000'],
];
1 month ago
if ($this->signupSchemaHasKey($competition, 'event_location')) {
$rules['event_location'] = ['nullable', 'string', Rule::in($this->allowedEventLocationValues($competition))];
}
if ($this->signupSchemaHasKey($competition, 'team_members')) {
$rules['team_members'] = ['nullable', 'string', 'max:500'];
5 months ago
}
2 months ago
foreach (SignupApplicationFieldRegistry::scalarColumnKeys() as $schemaOnlyKey) {
if (in_array($schemaOnlyKey, [
'player_name', 'school', 'degree', 'contact_email', 'contact_mobile',
'company_name', 'project_name', 'track',
'location_country', 'location_province', 'location_city', 'oversea_country', 'intro',
1 month ago
'event_location', 'team_members',
2 months ago
], true)) {
continue;
}
if ($this->signupSchemaHasKey($competition, $schemaOnlyKey)) {
$rules[$schemaOnlyKey] = ['nullable', 'string', 'max:255'];
}
3 months ago
}
5 months ago
if ($this->signupSchemaRequiresCommitment($competition)) {
$rules['commitment_accepted'] = ['sometimes', 'boolean'];
$rules['promise_signature'] = ['nullable', 'string', 'max:2097152'];
}
1 month ago
$rules = array_merge($rules, $this->extraSchemaScalarRules($competition, array_keys($rules)));
5 months ago
$data = $request->validate($rules);
$commitmentAccepted = $data['commitment_accepted'] ?? null;
$promiseSignature = $data['promise_signature'] ?? null;
unset($data['commitment_accepted'], $data['promise_signature']);
1 month ago
$this->persistSignupScalarAndAnswers($app, $data);
5 months ago
if ($this->signupSchemaRequiresCommitment($competition)) {
if ($commitmentAccepted === true) {
if (! is_string($promiseSignature) || trim($promiseSignature) === '') {
throw ValidationException::withMessages([
'promise_signature' => ['请完成手写签名后再保存'],
]);
}
$app->promise_signed_at = now();
$app->promise_signature = $promiseSignature;
} elseif ($commitmentAccepted === false) {
$app->promise_signed_at = null;
$app->promise_signature = null;
}
}
1 month ago
$this->syncUnsubmittedSignupLocale($request, $app);
5 months ago
$app->save();
$this->syncParticipantProfile($request->user(), $data, false);
5 months ago
$app->load(['files']);
return response()->json($this->transform($app, false));
5 months ago
}
public function submit(Request $request): JsonResponse
{
$competition = $this->resolvePublishedCompetitionFromRequest($request);
$app = $this->currentApplication($request);
$this->ensureParticipantMayEditSignup($app);
1 month ago
$this->normalizeSignupOptionInputs($request);
5 months ago
1 month ago
$trackCodes = $this->allowedTrackCodes($competition);
$degrees = SignupOptionCatalog::acceptedValues(SignupOptionCatalog::degrees(), 'degree');
$countries = SignupOptionCatalog::acceptedValues(SignupOptionCatalog::locationCountries(), 'location_country');
5 months ago
$companyRules = ['nullable', 'string', 'max:255'];
1 month ago
if ($this->signupSchemaHasKey($competition, 'company_name')) {
$companyRules = $this->signupSchemaFieldRequired($competition, 'company_name')
? ['required', 'string', 'max:255']
: ['nullable', 'string', 'max:255'];
}
$introRules = ['nullable', 'string', 'max:5000'];
if ($this->signupSchemaHasKey($competition, 'intro') && $this->signupSchemaFieldRequired($competition, 'intro')) {
$introRules = ['required', 'string', 'max:5000'];
5 months ago
}
5 months ago
$rules = [
'player_name' => ['required', 'string', 'max:120'],
'school' => ['required', 'string', 'max:200'],
'degree' => ['required', 'string', Rule::in($degrees)],
'contact_email' => ['required', 'email', 'max:255'],
'contact_mobile' => ['required', 'regex:/^1[3-9]\d{9}$/'],
5 months ago
'company_name' => $companyRules,
5 months ago
'project_name' => ['required', 'string', 'max:255'],
'track' => ['required', 'string', Rule::in($trackCodes)],
1 month ago
'track_other' => ['nullable', 'string', 'max:255'],
5 months ago
'location_country' => ['required', 'string', Rule::in($countries)],
1 month ago
'location_province' => ['nullable', 'string', 'max:100', Rule::requiredIf(
fn () => SignupOptionCatalog::normalize('location_country', $request->input('location_country')) === 'cn'
)],
'location_city' => ['nullable', 'string', 'max:100', Rule::requiredIf(
fn () => SignupOptionCatalog::normalize('location_country', $request->input('location_country')) === 'cn'
)],
'oversea_country' => ['nullable', 'string', 'max:100', Rule::requiredIf(
fn () => SignupOptionCatalog::normalize('location_country', $request->input('location_country')) === 'overseas'
)],
1 month ago
'intro' => $introRules,
5 months ago
];
1 month ago
if ($this->signupSchemaHasKey($competition, 'event_location')) {
$rules['event_location'] = ['required', 'string', Rule::in($this->allowedEventLocationValues($competition))];
}
if ($this->signupSchemaHasKey($competition, 'team_members')) {
$rules['team_members'] = $this->signupSchemaFieldRequired($competition, 'team_members')
? ['required', 'string', 'max:500']
: ['nullable', 'string', 'max:500'];
5 months ago
}
2 months ago
foreach (SignupApplicationFieldRegistry::scalarColumnKeys() as $schemaOnlyKey) {
if (in_array($schemaOnlyKey, [
'player_name', 'school', 'degree', 'contact_email', 'contact_mobile',
'company_name', 'project_name', 'track',
'location_country', 'location_province', 'location_city', 'oversea_country', 'intro',
1 month ago
'event_location', 'team_members',
2 months ago
], true)) {
continue;
}
if ($this->signupSchemaHasKey($competition, $schemaOnlyKey)) {
$rules[$schemaOnlyKey] = ['nullable', 'string', 'max:255'];
}
3 months ago
}
5 months ago
if ($this->signupSchemaRequiresCommitment($competition)) {
$rules['commitment_accepted'] = ['required', 'accepted'];
$rules['promise_signature'] = ['required', 'string', 'max:2097152'];
}
1 month ago
$rules = array_merge($rules, $this->extraSchemaScalarRules($competition, array_keys($rules)));
5 months ago
$data = $request->validate($rules);
$promiseSignature = $data['promise_signature'] ?? '';
unset($data['commitment_accepted'], $data['promise_signature']);
$planCount = $app->files()->where('kind', 'plan')->count();
if ($planCount < 1) {
throw ValidationException::withMessages([
'files' => ['请至少上传一份商业计划书'],
]);
}
4 months ago
$planRow = SignupFormFileRules::fileFieldRow($competition, 'plan');
$maxPlanFiles = SignupFormFileRules::maxCount($planRow);
if ($maxPlanFiles !== null && $planCount > $maxPlanFiles) {
throw ValidationException::withMessages([
'files' => ['商业计划书最多可上传 '.$maxPlanFiles.' 个文件'],
]);
}
if ($this->signupSchemaHasKey($competition, 'supporting')) {
$supportingCount = $app->files()->where('kind', 'supporting')->count();
$supRow = SignupFormFileRules::fileFieldRow($competition, 'supporting');
$maxSup = SignupFormFileRules::maxCount($supRow);
if ($maxSup !== null && $supportingCount > $maxSup) {
throw ValidationException::withMessages([
'files' => ['佐证材料最多可上传 '.$maxSup.' 个文件'],
]);
}
}
5 months ago
1 month ago
$this->persistSignupScalarAndAnswers($app, $data);
5 months ago
if ($this->signupSchemaRequiresCommitment($competition)) {
if (! is_string($promiseSignature) || trim($promiseSignature) === '') {
throw ValidationException::withMessages([
'promise_signature' => ['请完成参赛承诺书手写签名'],
]);
}
$app->promise_signature = $promiseSignature;
$app->promise_signed_at = now();
}
1 month ago
$this->lockSubmittedSignupLocale($request, $app);
5 months ago
$app->status = 'submitted';
$app->submitted_at = now();
$app->save();
$this->syncParticipantProfile($request->user(), $data, true);
5 months ago
$app->load(['files']);
$response = $this->transform($app);
$successNotice = $this->successNotice($competition);
if ($successNotice !== null) {
$response['success_notice'] = $successNotice;
}
$channelCallback = $this->channelCallback($app, $request);
if ($channelCallback !== null) {
$response['channel_callback'] = $channelCallback;
}
return response()->json($response);
}
/**
* @return array{enabled: true, message: string}|null
*/
private function successNotice(Competition $competition): ?array
{
$settings = is_array($competition->settings) ? $competition->settings : [];
$notice = $settings['success_notice'] ?? null;
if (! is_array($notice)) {
return null;
}
if (($notice['enabled'] ?? false) !== true) {
return null;
}
$message = trim((string) ($notice['message'] ?? ''));
if ($message === '') {
return null;
}
return [
'enabled' => true,
'message' => mb_substr($message, 0, 1000),
];
}
/**
* @return array<string, string>|null
*/
private function channelCallback(Application $app, Request $request): ?array
{
if ($app->signup_channel_id === null && trim((string) $app->signup_channel_code) === '') {
return null;
}
$context = [
'channel_code' => $app->signup_channel_code,
'competition_id' => $app->competition_id,
'mobile_masked' => $this->maskMobile((string) $request->user()?->mobile),
'state' => $app->signup_channel_state,
'user_id' => $app->user_id,
'application_id' => $app->id,
'result' => 'failed',
'error_code' => null,
'client_ip' => $request->ip(),
'callback_result' => 'failed',
'created_at' => now()->toIso8601String(),
];
try {
$channel = $app->signup_channel_id !== null
? SignupChannel::query()->find($app->signup_channel_id)
: null;
if ($channel === null && trim((string) $app->signup_channel_code) !== '') {
$channel = SignupChannel::query()
->where('channel_code', $app->signup_channel_code)
->first();
}
if ($channel === null || $channel->competition_id !== $app->competition_id) {
$context['error_code'] = 'CHANNEL_NOT_FOUND';
return null;
}
$context['channel_code'] = $channel->channel_code;
if ($channel->status !== SignupChannel::STATUS_ENABLED) {
$context['error_code'] = 'CHANNEL_DISABLED';
return null;
}
$callbackType = $channel->success_callback_type ?: SignupChannel::CALLBACK_TYPE_WEB;
if ($callbackType === SignupChannel::CALLBACK_TYPE_NONE) {
$context['error_code'] = 'CALLBACK_DISABLED';
return null;
}
if ($callbackType === SignupChannel::CALLBACK_TYPE_WEB
&& trim((string) $channel->success_callback_url) === '') {
$context['error_code'] = 'CALLBACK_MISSING';
return null;
}
if ($callbackType === SignupChannel::CALLBACK_TYPE_MINI_PROGRAM) {
4 months ago
$method = $channel->mini_program_callback_method
?: SignupChannel::MINI_PROGRAM_METHOD_REDIRECT_TO;
$callback = [
'type' => SignupChannel::CALLBACK_TYPE_MINI_PROGRAM,
4 months ago
'path' => ChannelCallbackUrlBuilder::buildMiniProgramPath(
$app,
$channel,
$method !== SignupChannel::MINI_PROGRAM_METHOD_SWITCH_TAB
),
'method' => $method,
];
} else {
$callback = [
'type' => SignupChannel::CALLBACK_TYPE_WEB,
'redirect_url' => ChannelCallbackUrlBuilder::build($app, $channel),
];
}
$context['result'] = 'success';
$context['callback_result'] = 'success';
return $callback;
} catch (Throwable $exception) {
$context['error_code'] = 'CALLBACK_INVALID';
Log::warning('channel.callback exception', [
'exception' => $exception::class,
]);
return null;
} finally {
Log::info('channel.callback', $context);
}
}
private function maskMobile(string $mobile): string
{
return preg_match('/^\d{11}$/', $mobile)
? substr($mobile, 0, 3).'****'.substr($mobile, -4)
: '***';
5 months ago
}
/**
* 报名联系邮箱保存在 applications.contact_email;同步到 users.email 只是资料补全。
* users.email 有唯一索引,若邮箱已属于其他账号,跳过同步,避免报名保存/提交被唯一键冲突阻断。
*
* @param array<string, mixed> $data
*/
private function syncParticipantProfile(User $user, array $data, bool $overwriteName): void
{
$updates = [];
$name = trim((string) ($data['player_name'] ?? ''));
if ($name !== '' && ($overwriteName || empty($user->name))) {
$updates['name'] = $name;
}
$email = trim((string) ($data['contact_email'] ?? ''));
if ($email !== '' && ($overwriteName || empty($user->email) || $user->email === $email)) {
$emailOwnedByOtherUser = User::query()
->where('email', $email)
->whereKeyNot($user->id)
->exists();
if (! $emailOwnedByOtherUser) {
$updates['email'] = $email;
}
}
if ($updates !== []) {
$user->update($updates);
}
}
private function isBlankSignupValue(mixed $value): bool
{
return $value === null || (is_string($value) && trim($value) === '');
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function applySignupPrefillSources(Application $app, array $payload): array
{
$app->loadMissing(['user', 'competition.formSchema']);
$rows = $app->competition?->formSchema?->schema_json;
if (! is_array($rows)) {
return $payload;
}
foreach ($rows as $row) {
if (! is_array($row)) {
continue;
}
$key = trim((string) ($row['key'] ?? ''));
$source = trim((string) ($row['prefill_from'] ?? ''));
if ($key === '' || $source === '' || ! array_key_exists($source, self::USER_PREFILL_SOURCES)) {
continue;
}
if (! array_key_exists($key, $payload) || ! $this->isBlankSignupValue($payload[$key])) {
continue;
}
$userColumn = self::USER_PREFILL_SOURCES[$source];
$value = $app->user?->{$userColumn};
if ($this->isBlankSignupValue($value)) {
continue;
}
$payload[$key] = $value;
}
return $payload;
}
private function transform(Application $app, bool $includeSignature = true): array
5 months ago
{
2 months ago
$payload = array_merge([
5 months ago
'id' => $app->id,
'competition_id' => $app->competition_id,
'status' => $app->status,
1 month ago
'signup_locale' => $app->signup_locale ?: 'zh-CN',
'signup_locale_locked' => $app->isSubmitted(),
5 months ago
'promise_signed_at' => $app->promise_signed_at?->toIso8601String(),
'submitted_at' => $app->submitted_at?->toIso8601String(),
'participant_may_edit' => $app->participantMayEditSignup(),
'participant_may_edit_files' => $app->participantMayEditFiles(),
5 months ago
'files' => $app->files->map(fn ($f) => [
'id' => $f->id,
'kind' => $f->kind,
'original_name' => $f->original_name,
'size' => $f->size,
5 months ago
'url' => $f->participantPreviewSignedUrl(),
5 months ago
])->values(),
2 months ago
], SignupApplicationFieldRegistry::scalarAttributesFromApplication($app));
1 month ago
$answers = is_array($app->answers_json) ? $app->answers_json : [];
foreach ($answers as $key => $value) {
if (! array_key_exists($key, $payload)) {
$payload[$key] = $value;
}
}
if ($includeSignature) {
$payload['promise_signature'] = $app->promise_signature;
}
return $this->applySignupPrefillSources($app, $payload);
5 months ago
}
1 month ago
private function normalizeSignupOptionInputs(Request $request): void
{
$merge = [];
foreach (['degree', 'location_country', 'event_location'] as $kind) {
if ($request->exists($kind)) {
$merge[$kind] = SignupOptionCatalog::normalize($kind, is_string($request->input($kind)) ? $request->input($kind) : null);
}
}
if ($merge !== []) {
$request->merge($merge);
}
}
/**
* @param array<string, mixed> $data
*/
private function persistSignupScalarAndAnswers(Application $app, array $data): void
{
if (array_key_exists('track', $data) && (string) ($data['track'] ?? '') !== SignupDisplay::TRACK_OTHER_CODE) {
$data['track_other'] = '';
}
$app->fill(SignupApplicationFieldRegistry::filterFillableScalarData($data));
$answers = SignupApplicationFieldRegistry::filterAnswersJsonData($data);
if ($answers !== []) {
$app->answers_json = array_merge(is_array($app->answers_json) ? $app->answers_json : [], $answers);
}
}
/**
* @param list<string> $existingKeys
* @return array<string, list<mixed>>
*/
private function extraSchemaScalarRules(Competition $competition, array $existingKeys): array
{
$competition->loadMissing('formSchema');
$schema = $competition->formSchema?->schema_json;
$keys = SignupApplicationFieldRegistry::keysFromSchemaJson(is_array($schema) ? $schema : null);
$skip = array_flip([
...$existingKeys,
...SignupApplicationFieldRegistry::FILE_FIELD_KEYS,
SignupApplicationFieldRegistry::COMMITMENT_KEY,
SignupDisplay::TRACK_OTHER_FIELD,
]);
$rules = [];
foreach ($keys as $key) {
if (isset($skip[$key])) {
continue;
}
$rules[$key] = ['nullable', 'string', 'max:5000'];
}
return $rules;
}
private function requestPortalLocale(Request $request): ?string
{
$lang = $request->query('lang') ?? $request->input('lang');
if (! is_string($lang) || trim($lang) === '') {
return null;
}
return PortalLocale::fromRequest($lang);
}
private function syncUnsubmittedSignupLocale(Request $request, Application $app): void
{
if ($app->isSubmitted()) {
return;
}
$locale = $this->requestPortalLocale($request);
if ($locale === null) {
return;
}
CompetitionPortalLocale::remember(
(int) $app->user_id,
(int) $app->competition_id,
PortalLocale::PORTAL_PARTICIPANT,
$locale
);
if (Schema::hasColumn($app->getTable(), 'signup_locale')) {
$app->signup_locale = $locale;
}
}
private function lockSubmittedSignupLocale(Request $request, Application $app): void
{
$locale = $this->requestPortalLocale($request) ?? PortalLocale::ZH;
CompetitionPortalLocale::remember(
(int) $app->user_id,
(int) $app->competition_id,
PortalLocale::PORTAL_PARTICIPANT,
$locale
);
if (Schema::hasColumn($app->getTable(), 'signup_locale')) {
$app->signup_locale = $locale;
}
}
5 months ago
}