diff --git a/backend/app/Http/Controllers/Api/AuthController.php b/backend/app/Http/Controllers/Api/AuthController.php index 000d455..6f65e52 100644 --- a/backend/app/Http/Controllers/Api/AuthController.php +++ b/backend/app/Http/Controllers/Api/AuthController.php @@ -6,8 +6,10 @@ use Illuminate\Http\Request; use App\Models\User; use Illuminate\Validation\ValidationException; -use App\Http\Requests\StoreUserRequest; +use App\Http\Requests\{StoreUserRequest, GoogleRequest}; use Illuminate\Support\Facades\Hash; +use Laravel\Socialite\Socialite; + class AuthController extends Controller { @@ -54,4 +56,48 @@ public function logout(Request $request){ $user->tokens()->delete(); return response()->json(["message" => "Logged out successfully " . $user->name]); } + + + + + + public function google(GoogleRequest $request) + { + $token = $request->validated()['token']; + + try { + // userFromToken() works with access tokens + // take access token from frontend and verify it + $googleUser = Socialite::driver('google') + ->stateless() + ->userFromToken($token); + + } catch (\Exception $e) { + return response()->json([ + 'message' => 'Invalid Google token', + ], 401); + } + + $user = User::firstOrCreate( + ['email' => $googleUser->getEmail()], + [ + 'name' => $googleUser->getName(), + 'google_id' => $googleUser->getId(), + 'password' => null, + 'email_verified_at' => now(), + ] + ); + + if (!$user->google_id) { + $user->update([ + 'google_id' => $googleUser->getId(), + ]); + // google_id here in case the user changed their email but not their entire account + } + + $token = $user->createToken('api-token')->plainTextToken; + + return response()->json(['token' => $token, 'user' => $user]); + } + } diff --git a/backend/app/Http/Controllers/Api/ChannelController.php b/backend/app/Http/Controllers/Api/ChannelController.php new file mode 100644 index 0000000..b6c286e --- /dev/null +++ b/backend/app/Http/Controllers/Api/ChannelController.php @@ -0,0 +1,75 @@ +authorizeResource(Channel::class, 'channel'); + } + + + + + public function index(Request $request) + { + $type = $request->input('type'); // owned | joined + + $query = match ($type) { + 'owned' => Channel::owned(auth()->user()), + 'joined' => Channel::joined(auth()->user()), + default => Channel::query(), + }; + + $channels = $query + ->withCount('members') + ->latest() + ->paginate($this->paginate); + + return ChannelResource::collection($channels); + } + + /** + * Store a newly created resource in storage. + */ + public function store(StoreChannelRequest $request) + { + $channel = auth()->user()->channels()->create($request->validated()); + return new ChannelResource($channel); + } + + /** + * Display the specified resource. + */ + public function show(Channel $channel) + { + $channel->load(['members.user', 'invitations']) + ->loadCount('members'); + + return new ChannelResource($channel); + } + + /** + * Update the specified resource in storage. + */ + public function update(UpdateChannelRequest $request, Channel $channel) + { + $validated = $request->validated(); + $channel->update($validated); + return new ChannelResource($channel); + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(Channel $channel) + { + $channel->delete(); + return new ChannelResource($channel); + } +} diff --git a/backend/app/Http/Controllers/Api/CommentController.php b/backend/app/Http/Controllers/Api/CommentController.php new file mode 100644 index 0000000..48379f9 --- /dev/null +++ b/backend/app/Http/Controllers/Api/CommentController.php @@ -0,0 +1,54 @@ +authorize('viewAny', [Comment::class, $sharedDay->channel]); + + return CommentResource::collection( + $sharedDay->comments() + ->with(['author', 'sharedDay.channel']) + ->latest() + ->paginate($this->paginate) + ); + } + + public function store(StoreCommentRequest $request, SharedDay $sharedDay) + { + $this->authorize('create', [Comment::class, $sharedDay->channel]); + + $comment = $sharedDay->comments()->create([ + ...$request->validated(), + 'user_id' => auth()->id() + ]); + + return new CommentResource($comment->load(['author', 'sharedDay.channel'])); + } + + public function update(UpdateCommentRequest $request, SharedDay $sharedDay, Comment $comment) + { + $this->authorize('update', $comment); + + $comment->update($request->validated()); + + return new CommentResource($comment->load(['author', 'sharedDay.channel'])); + } + + public function destroy(SharedDay $sharedDay, Comment $comment) + { + $this->authorize('delete', [$comment, $sharedDay->channel]); + + $comment->delete(); + + return new CommentResource($comment); + } + +} diff --git a/backend/app/Http/Controllers/Api/InvitationController.php b/backend/app/Http/Controllers/Api/InvitationController.php new file mode 100644 index 0000000..e0abc99 --- /dev/null +++ b/backend/app/Http/Controllers/Api/InvitationController.php @@ -0,0 +1,52 @@ +authorize('viewAny', [Invitation::class, $channel]); + $invitations = Invitation::query() + ->forChannel($channel) + ->pending() + ->with(['invitedUser', 'channel']) + ->latest() + ->paginate($this->paginate); + + + return InvitationResource::collection($invitations); + } + + + + public function store(StoreInvitationRequest $request, Channel $channel, InviteService $service ) + { + $validated = $request->validated(); + $identifier = $validated['identifier']; + $invitation = $service->invite($identifier, $channel); + return new InvitationResource($invitation); + } + + public function update(UpdateInvitationRequest $request, Channel $channel, Invitation $invitation, InvitationStatusService $service) + { + $validated = $request->validated(); + $status = $validated['status']; + + match ($status) { + 'accepted' => $this->authorize('accept', $invitation), + 'declined' => $this->authorize('decline', $invitation), + 'cancelled' => $this->authorize('cancel', $invitation), + }; + + $service->changeStatus($invitation, $status, auth()->user()); + return new InvitationResource($invitation); + } + +} diff --git a/backend/app/Http/Controllers/Api/MeController.php b/backend/app/Http/Controllers/Api/MeController.php new file mode 100644 index 0000000..5e128fa --- /dev/null +++ b/backend/app/Http/Controllers/Api/MeController.php @@ -0,0 +1,43 @@ +received($request->user()) + ->pending() + ->with(['invitedBy', 'channel']) + ->latest() + ->paginate($this->paginate); + + return InvitationResource::collection($invitations); + } + + public function sharedDays() + { + $sharedDays = auth()->user() + ->sharedDays() + ->with('channel') + ->get() + ->groupBy('date'); + + return $sharedDays->map(function ($days, $date) { + return [ + 'date' => $date, + 'channels' => $days->map(fn($day) => [ + 'id' => $day->channel->id, + 'name' => $day->channel->name, + 'shared_day_id' => $day->id, + ]) + ]; + })->values(); + } +} \ No newline at end of file diff --git a/backend/app/Http/Controllers/Api/ReportController.php b/backend/app/Http/Controllers/Api/ReportController.php new file mode 100644 index 0000000..798c98c --- /dev/null +++ b/backend/app/Http/Controllers/Api/ReportController.php @@ -0,0 +1,20 @@ +generate(auth()->id(), + $request->validated()['from'], + $request->validated()['to'], + $request->boolean('refresh')); + } + + +} diff --git a/backend/app/Http/Controllers/Api/SharedDayController.php b/backend/app/Http/Controllers/Api/SharedDayController.php new file mode 100644 index 0000000..3a9925a --- /dev/null +++ b/backend/app/Http/Controllers/Api/SharedDayController.php @@ -0,0 +1,68 @@ +authorize('viewAny', $channel); + + return SharedDayResource::collection( + $channel->sharedDays()->paginate($this->paginate) + ); + } + + // GET /channels/{channel}/shared-days/{shared_day} + public function show(Channel $channel, SharedDay $sharedDay) + { + $this->authorize('view', $channel); + + return new SharedDayResource($sharedDay->load(['entries.timeEntry', 'channel'])); + } + + // POST /shared-days + public function store(StoreSharedDayRequest $request) + { + $sharedDays = collect(); + + DB::transaction(function () use ($request, &$sharedDays) { + foreach ($request->channel_ids as $channelId) { + $channel = Channel::findOrFail($channelId); + $this->authorize('create', [SharedDay::class, $channel]); + + $sharedDay = SharedDay::firstOrCreate([ + 'channel_id' => $channelId, + 'user_id' => auth()->id(), + 'date' => $request->date, + ]); + + foreach ($request->entry_ids as $entryId) { + $sharedDay->entries()->firstOrCreate([ + 'time_entry_id' => $entryId + ]); + } + + $sharedDay->load(['entries.timeEntry', 'channel']); + $sharedDays->push($sharedDay); + } + }); + + return SharedDayResource::collection($sharedDays); + } + + public function destroy(Channel $channel, SharedDay $sharedDay) + { + $this->authorize('delete', $sharedDay); + + $sharedDay->delete(); + return new SharedDayResource($sharedDay); + } +} diff --git a/backend/app/Http/Controllers/Api/TimeEntryController.php b/backend/app/Http/Controllers/Api/TimeEntryController.php index 26fb53c..e07ea57 100644 --- a/backend/app/Http/Controllers/Api/TimeEntryController.php +++ b/backend/app/Http/Controllers/Api/TimeEntryController.php @@ -1,11 +1,10 @@ only(['date', 'sort', 'search', 'history']); - if ($request->boolean('history')) { - $dates = auth()->user() - ->time_entries() - ->history() - ->pluck('date'); - return response()->json($dates); - } + if ($request->boolean('history')) { + $dates = auth()->user() + ->time_entries() + ->history() + ->paginate($this->paginate); + return DateResource::collection($dates); + } + // created an entirly new resource to handle the history to keep the pagination structure consistent throughout the app $time_entries = auth()->user() ->time_entries() ->search($filters) - ->paginate(15); + ->paginate($this->paginate); return TimeEntryResource::collection($time_entries); } + public function store(StoreTimeEntryRequest $request) { $validated = $request->validated(); @@ -54,4 +55,4 @@ public function destroy(TimeEntry $time_entry) $time_entry->delete(); return new TimeEntryResource($time_entry); } -} \ No newline at end of file +} diff --git a/backend/app/Http/Controllers/Api/UserController.php b/backend/app/Http/Controllers/Api/UserController.php new file mode 100644 index 0000000..79f3a82 --- /dev/null +++ b/backend/app/Http/Controllers/Api/UserController.php @@ -0,0 +1,21 @@ +input("search"); + $query = User::query(); + $users = $query->searchByNameOrEmail($identifier)->get(); + return UserResource::collection($users); + + } + + +} diff --git a/backend/app/Http/Controllers/Controller.php b/backend/app/Http/Controllers/Controller.php index 77ec359..2fa076b 100644 --- a/backend/app/Http/Controllers/Controller.php +++ b/backend/app/Http/Controllers/Controller.php @@ -9,4 +9,5 @@ class Controller extends BaseController { use AuthorizesRequests, ValidatesRequests; + public int $paginate = 10; } diff --git a/backend/app/Http/Requests/GoogleRequest.php b/backend/app/Http/Requests/GoogleRequest.php new file mode 100644 index 0000000..d6deeae --- /dev/null +++ b/backend/app/Http/Requests/GoogleRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'token' => 'required|string' + ]; + } +} diff --git a/backend/app/Http/Requests/ReportRequest.php b/backend/app/Http/Requests/ReportRequest.php new file mode 100644 index 0000000..8a8701c --- /dev/null +++ b/backend/app/Http/Requests/ReportRequest.php @@ -0,0 +1,29 @@ +|string> + */ + public function rules(): array + { + return [ + 'from' => 'nullable|date|date_format:Y-m-d', + 'to' => 'nullable|date|date_format:Y-m-d|after_or_equal:from' + ]; + } +} diff --git a/backend/app/Http/Requests/StoreChannelRequest.php b/backend/app/Http/Requests/StoreChannelRequest.php new file mode 100644 index 0000000..1ca842f --- /dev/null +++ b/backend/app/Http/Requests/StoreChannelRequest.php @@ -0,0 +1,29 @@ +|string> + */ + public function rules(): array + { + return [ + "name" => "required|max:255", + + ]; + } +} diff --git a/backend/app/Http/Requests/StoreCommentRequest.php b/backend/app/Http/Requests/StoreCommentRequest.php new file mode 100644 index 0000000..6d668cd --- /dev/null +++ b/backend/app/Http/Requests/StoreCommentRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + "body"=>"required|string|min:1|max:1000" + ]; + } +} diff --git a/backend/app/Http/Requests/StoreInvitationRequest.php b/backend/app/Http/Requests/StoreInvitationRequest.php new file mode 100644 index 0000000..0dbc74f --- /dev/null +++ b/backend/app/Http/Requests/StoreInvitationRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'identifier' => 'required|string', + ]; + } +} diff --git a/backend/app/Http/Requests/StoreSharedDayRequest.php b/backend/app/Http/Requests/StoreSharedDayRequest.php new file mode 100644 index 0000000..b58bbb4 --- /dev/null +++ b/backend/app/Http/Requests/StoreSharedDayRequest.php @@ -0,0 +1,50 @@ + ['required', 'date'], + 'channel_ids' => ['required', 'array', 'min:1'], + 'channel_ids.*' => ['required', 'exists:channels,id'], + 'entry_ids' => ['required', 'array', 'min:1'], + 'entry_ids.*' => ['required', 'exists:time_entries,id'], + ]; + } + + public function withValidator($validator): void + { + $validator->after(function ($validator) { + + $entryIds = TimeEntry::where('user_id', auth()->id()) + ->date($this->date) + ->pluck('id'); + + if ($entryIds->isEmpty()) { + $validator->errors()->add('date', 'This day has no time entries.'); + return; + } + + if ($this->entry_ids) { + $invalid = collect($this->entry_ids)->diff($entryIds); + + if ($invalid->isNotEmpty()) { + $validator->errors()->add( + 'entry_ids', + 'Some entries do not belong to this day or to you.' + ); + } + } + }); + } +} \ No newline at end of file diff --git a/backend/app/Http/Requests/UpdateChannelRequest.php b/backend/app/Http/Requests/UpdateChannelRequest.php new file mode 100644 index 0000000..c66531b --- /dev/null +++ b/backend/app/Http/Requests/UpdateChannelRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + "name" => "sometimes|max:255", + ]; + } +} diff --git a/backend/app/Http/Requests/UpdateCommentRequest.php b/backend/app/Http/Requests/UpdateCommentRequest.php new file mode 100644 index 0000000..13d2988 --- /dev/null +++ b/backend/app/Http/Requests/UpdateCommentRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + "body"=> "sometimes|string|min:1|max:255" + ]; + } +} diff --git a/backend/app/Http/Requests/UpdateInvitationRequest.php b/backend/app/Http/Requests/UpdateInvitationRequest.php new file mode 100644 index 0000000..f7de4e5 --- /dev/null +++ b/backend/app/Http/Requests/UpdateInvitationRequest.php @@ -0,0 +1,25 @@ +|string> + */ + public function rules(): array + { + return [ + 'status' => ['required', 'in:accepted,declined,cancelled'], + ]; + } +} diff --git a/backend/app/Http/Requests/UpdateTimeEntryRequest.php b/backend/app/Http/Requests/UpdateTimeEntryRequest.php index 8557441..5098744 100644 --- a/backend/app/Http/Requests/UpdateTimeEntryRequest.php +++ b/backend/app/Http/Requests/UpdateTimeEntryRequest.php @@ -11,7 +11,7 @@ class UpdateTimeEntryRequest extends FormRequest */ public function authorize(): bool { - return false; + return true; } /** diff --git a/backend/app/Http/Resources/ChannelResource.php b/backend/app/Http/Resources/ChannelResource.php new file mode 100644 index 0000000..550b90a --- /dev/null +++ b/backend/app/Http/Resources/ChannelResource.php @@ -0,0 +1,19 @@ + + */ + public function toArray(Request $request): array + { + return parent::toArray($request); + } +} diff --git a/backend/app/Http/Resources/CommentResource.php b/backend/app/Http/Resources/CommentResource.php new file mode 100644 index 0000000..98e27c7 --- /dev/null +++ b/backend/app/Http/Resources/CommentResource.php @@ -0,0 +1,25 @@ + $this->id, + 'body' => $this->body, + 'created_at' => $this->created_at->format('M d, Y h:i A'), + "shared_day_id" => $this->shared_day_id, + 'author' => new UserResource($this->whenLoaded('author')), + 'can_edit' => auth()->id() === $this->user_id, + 'can_delete' => auth()->id() === $this->user_id || + $this->whenLoaded('sharedDay', fn() => + $this->sharedDay->channel->isOwner(auth()->user()) + , false) + ]; + } +} diff --git a/backend/app/Http/Resources/DateResource.php b/backend/app/Http/Resources/DateResource.php new file mode 100644 index 0000000..9cf8a10 --- /dev/null +++ b/backend/app/Http/Resources/DateResource.php @@ -0,0 +1,19 @@ + + */ + public function toArray(Request $request): array + { + return ['date'=>$this->date]; + } +} diff --git a/backend/app/Http/Resources/InvitationResource.php b/backend/app/Http/Resources/InvitationResource.php new file mode 100644 index 0000000..6d7f8b7 --- /dev/null +++ b/backend/app/Http/Resources/InvitationResource.php @@ -0,0 +1,19 @@ + + */ + public function toArray(Request $request): array + { + return parent::toArray($request); + } +} diff --git a/backend/app/Http/Resources/SharedDayEntryResource.php b/backend/app/Http/Resources/SharedDayEntryResource.php new file mode 100644 index 0000000..496e59f --- /dev/null +++ b/backend/app/Http/Resources/SharedDayEntryResource.php @@ -0,0 +1,23 @@ + + */ + public function toArray($request): array + { + return [ + 'id' => $this->id, + 'time_entry' => new TimeEntryResource($this->timeEntry) + ]; + } +} diff --git a/backend/app/Http/Resources/SharedDayResource.php b/backend/app/Http/Resources/SharedDayResource.php new file mode 100644 index 0000000..fc16f66 --- /dev/null +++ b/backend/app/Http/Resources/SharedDayResource.php @@ -0,0 +1,22 @@ + $this->id, + 'date' => $this->date->format('Y-m-d'), + 'channel_id' => $this->channel_id, + 'total_time' => $this->total_time, + 'entries_count' => $this->entries_count, + 'entries' => SharedDayEntryResource::collection($this->whenLoaded('entries')), + 'channel' => new ChannelResource($this->whenLoaded('channel')), + ]; + } +} \ No newline at end of file diff --git a/backend/app/Http/Resources/TimeEntryResource.php b/backend/app/Http/Resources/TimeEntryResource.php index 9d14946..96bdabd 100644 --- a/backend/app/Http/Resources/TimeEntryResource.php +++ b/backend/app/Http/Resources/TimeEntryResource.php @@ -22,7 +22,7 @@ public function toArray(Request $request): array 'start_time' => $this->start_time->format('g:i a'), 'end_time' => $this->end_time->format('g:i a'), 'time_taken' => $this->time_taken, - 'create_at' => $this->created_at + 'created_at' => $this->created_at ]; diff --git a/backend/app/Http/Resources/UserResource.php b/backend/app/Http/Resources/UserResource.php new file mode 100644 index 0000000..3b6b127 --- /dev/null +++ b/backend/app/Http/Resources/UserResource.php @@ -0,0 +1,19 @@ + + */ + public function toArray(Request $request): array + { + return parent::toArray($request); + } +} diff --git a/backend/app/Models/Channel.php b/backend/app/Models/Channel.php new file mode 100644 index 0000000..188982c --- /dev/null +++ b/backend/app/Models/Channel.php @@ -0,0 +1,70 @@ +belongsTo(User::class, 'user_id'); + } + + public function members(): HasMany + { + return $this->hasMany(Member::class); + } + + + public function invitations(): HasMany + { + return $this->hasMany(Invitation::class); + } + + public function sharedDays(): HasMany + { + return $this->hasMany(SharedDay::class); + } + + public function scopeOwned(Builder $query, User $user): Builder{ + return $query->where('user_id', $user->id); + } + + public function scopeJoined(Builder $query, User $user ): Builder{ + return $query->whereHas('members', fn($q) => $q->where('user_id', $user->id)); + } + public function isOwner(User $user): bool{ + return $this->user_id === $user->id; + } + public function isMember(User $user): bool{ + return $this->members()->where('user_id', $user->id)->exists(); + } + public function isOwnerOrMember(User $user): bool{ + return $this->isOwner($user) || $this->isMember($user); + } + + + + + + + + + + + + + + + + + + +} diff --git a/backend/app/Models/Comment.php b/backend/app/Models/Comment.php new file mode 100644 index 0000000..bb31f52 --- /dev/null +++ b/backend/app/Models/Comment.php @@ -0,0 +1,27 @@ + 'datetime: M d, Y h:i A', + ]; + + public function sharedDay(): BelongsTo + { + return $this->belongsTo(SharedDay::class); + } + + public function author(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/backend/app/Models/Invitation.php b/backend/app/Models/Invitation.php new file mode 100644 index 0000000..a278702 --- /dev/null +++ b/backend/app/Models/Invitation.php @@ -0,0 +1,54 @@ +belongsTo(Channel::class); + } + + public function invitedUser(): BelongsTo + { + return $this->belongsTo(User::class, 'invited_id'); + } + + public function invitedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'invited_by_id'); + } + + public function logs(): HasMany + { + return $this->hasMany(InvitationLog::class); + } + + public function scopePending(Builder $query): Builder + { + return $query->where('status', 'pending'); + } + + public function scopeReceived(Builder $query, User $user): Builder + { + return $query->where('invited_id', $user->id); + } + + public function scopeForChannel(Builder $query, Channel $channel): Builder + { + return $query->where('channel_id', $channel->id); + } +} diff --git a/backend/app/Models/InvitationLog.php b/backend/app/Models/InvitationLog.php new file mode 100644 index 0000000..5664126 --- /dev/null +++ b/backend/app/Models/InvitationLog.php @@ -0,0 +1,20 @@ +belongsTo(Invitation::class); + } + public function changed_by(): BelongsTo{ + return $this->belongsTo(User::class); + } + +} diff --git a/backend/app/Models/Member.php b/backend/app/Models/Member.php new file mode 100644 index 0000000..56914ad --- /dev/null +++ b/backend/app/Models/Member.php @@ -0,0 +1,24 @@ +belongsTo(Channel::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/backend/app/Models/SharedDay.php b/backend/app/Models/SharedDay.php new file mode 100644 index 0000000..e2189a8 --- /dev/null +++ b/backend/app/Models/SharedDay.php @@ -0,0 +1,61 @@ + 'date:Y-m-d', + ]; + protected $appends = ['entries_count', 'total_hours']; + + public function channel() + { + return $this->belongsTo(Channel::class); + } + + public function sharedBy() + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function entries() + { + return $this->hasMany(SharedDayEntry::class); + } + + public function comments() + { + return $this->hasMany(Comment::class); + } + protected function totalTime(): Attribute + { + return Attribute::make( + get: function () { + $totalMinutes = $this->entries->sum(function ($entry) { + $timeTaken = $entry->timeEntry->time_taken; + return ($timeTaken['hours'] * 60) + $timeTaken['minutes']; + }); + + return [ + 'hours' => intdiv($totalMinutes, 60), + 'minutes' => $totalMinutes % 60 + ]; + } + ); + } + protected function entriesCount() : Attribute + { + return Attribute::make( + get: fn()=> $this->entries()->count() + ); + } +} diff --git a/backend/app/Models/SharedDayEntry.php b/backend/app/Models/SharedDayEntry.php new file mode 100644 index 0000000..44da113 --- /dev/null +++ b/backend/app/Models/SharedDayEntry.php @@ -0,0 +1,24 @@ +belongsTo(SharedDay::class); + } + + public function timeEntry(): BelongsTo + { + return $this->belongsTo(TimeEntry::class); + } +} diff --git a/backend/app/Models/TimeEntry.php b/backend/app/Models/TimeEntry.php index 77aede0..550ed21 100644 --- a/backend/app/Models/TimeEntry.php +++ b/backend/app/Models/TimeEntry.php @@ -3,32 +3,30 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\{Builder, Model}; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Support\Carbon; -use Illuminate\Database\Eloquent\Builder; class TimeEntry extends Model { use HasFactory; - + protected $fillable = [ 'user_id', 'label', 'start_time', 'end_time', ]; - + protected $casts = [ 'created_at' => 'datetime:Y-m-d', - 'start_time' => 'datetime', - 'end_time' => 'datetime' + 'start_time' => 'datetime:h:i A', + 'end_time' => 'datetime:h:i A', ]; - public function user() { return $this->belongsTo(User::class); } - + protected function timeTaken(): Attribute { return Attribute::make( @@ -40,55 +38,76 @@ protected function timeTaken(): Attribute } -public function scopeDate(Builder $query, string $date): Builder +public function scopeDate(Builder $query, $date): Builder { - return $query->whereDate( - 'created_at', - Carbon::parse($date) - ); + $date = $date instanceof Carbon ? $date : Carbon::parse($date); + + return $query + ->where('created_at', '>=', $date->copy()->startOfDay()) + ->where('created_at', '<', $date->copy()->addDay()->startOfDay()); + // I added copy() because these carbon methdods actullay mutate the instance so we end up ranging the same value } + + public function scopeToday(Builder $query): Builder { - return $query->whereDate( - 'created_at', - today() - ); + return $query->date(today()); } + public function scopeSearchByLabel(Builder $query, string $search): Builder { return $query->where('label', 'LIKE', '%' . $search . '%'); } public function scopeSort(Builder $query, string $sort): Builder { - $query->when($sort ?? null, - fn($q, $sort) => $q->orderBy('created_at', $sort), + $query->when($sort ?? null, + fn($q, $sort) => $q->orderBy('created_at', $sort), fn($q) => $q->orderBy('created_at', 'desc') ); return $query; } + public function scopeHistory(Builder $query): Builder { return $query->selectRaw('DATE(created_at) as date') - ->distinct() + ->groupBy('date') ->orderBy('date', 'desc'); - } - public function scopeSearch($query, array $filters): Builder { - $query->when($filters['date'] ?? null, - fn($q, $date) => $q->date($date), + $query->when($filters['date'] ?? null, + fn($q, $date) => $q->date($date), fn($q) => $q->today() ) - ->when($filters['sort'] ?? null, + ->when($filters['sort'] ?? null, fn($q, $sort) => $q->sort($sort) ) ->when($filters['search']?? null, fn($q,$search)=> $q->searchByLabel($search)) ; - - return $query; -}} \ No newline at end of file + + return $query; +} + + +public function scopeInRange($query, ?string $from, ?string $to): Builder +{ + $from = $from + ? Carbon::parse($from)->startOfDay() + : Carbon::parse($query->min('created_at') ?? now()->subYear())->startOfDay(); + + $to = $to + ? Carbon::parse($to)->endOfDay() + : now()->endOfDay(); + + return $query->whereBetween('created_at', [$from, $to]); +} +// new +public function sharedDayEntries() +{ + return $this->hasMany(SharedDayEntry::class); +} +} diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 644ad90..a209780 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -7,14 +7,39 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; +use Illuminate\Database\Eloquent\Builder; class User extends Authenticatable { use HasApiTokens, HasFactory, Notifiable; - public function time_entries(){ + public function time_entries(){ return $this->hasMany(TimeEntry::class); - } + } + public function channels() + { + return $this->hasMany(Channel::class); + } + + public function memberships() + { + return $this->hasMany(Member::class); + } + + public function invitations() + { + return $this->hasMany(Invitation::class); + } + + public function sharedDays() + { + return $this->hasMany(SharedDay::class); + } + + public function comments() + { + return $this->hasMany(Comment::class); + } /** * The attributes that are mass assignable. @@ -24,7 +49,6 @@ public function time_entries(){ protected $fillable = [ 'name', 'email', - 'password', ]; /** @@ -46,4 +70,10 @@ public function time_entries(){ 'email_verified_at' => 'datetime', 'password' => 'hashed', ]; + + public function scopeSearchByNameOrEmail(Builder $query, string $search): ?Builder { + return $query->where('name', 'LIKE', '%' . $search . '%') + ->orWhere('email', 'LIKE', '%' . $search . '%') + ; + } } diff --git a/backend/app/Observers/InvitationObserver.php b/backend/app/Observers/InvitationObserver.php new file mode 100644 index 0000000..37d7d8a --- /dev/null +++ b/backend/app/Observers/InvitationObserver.php @@ -0,0 +1,38 @@ + $invitation->id, + 'user_id' => $invitation->invited_by_id, + 'action' => 'sent', + ]); + } + + public function updated(Invitation $invitation): void + { + if (! $invitation->wasChanged('status')) { + return; + } + + if (! in_array($invitation->status, ['accepted', 'declined', 'cancelled'], true)) { + return; + } + + InvitationLog::create([ + 'invitation_id' => $invitation->id, + 'user_id' => in_array($invitation->status, ['accepted', 'declined'], true) + ? $invitation->invited_id + : $invitation->invited_by_id, + 'action' => $invitation->status, + ]); + } +} diff --git a/backend/app/Observers/TimeEntryObserver.php b/backend/app/Observers/TimeEntryObserver.php new file mode 100644 index 0000000..e73bc0e --- /dev/null +++ b/backend/app/Observers/TimeEntryObserver.php @@ -0,0 +1,18 @@ +duration_minutes = $entry->start_time->diffInMinutes($entry->end_time); + } + + public function updating(TimeEntry $entry): void + { + $entry->duration_minutes = $entry->start_time->diffInMinutes($entry->end_time); + } +} diff --git a/backend/app/Policies/ChannelPolicy.php b/backend/app/Policies/ChannelPolicy.php new file mode 100644 index 0000000..565b170 --- /dev/null +++ b/backend/app/Policies/ChannelPolicy.php @@ -0,0 +1,34 @@ +isOwnerOrMember($user); + } + + public function create(User $user): bool + { + return true; + } + + public function update(User $user, Channel $channel): bool + { + return $channel->isOwner($user); + } + + public function delete(User $user, Channel $channel): bool + { + return $channel->isOwner($user); + } + + +} diff --git a/backend/app/Policies/CommentPolicy.php b/backend/app/Policies/CommentPolicy.php new file mode 100644 index 0000000..20e4742 --- /dev/null +++ b/backend/app/Policies/CommentPolicy.php @@ -0,0 +1,28 @@ +isOwnerOrMember($user); + } + + public function create(User $user, Channel $channel): bool + { + return $channel->isOwnerOrMember($user); + } + + public function update(User $user, Comment $comment): bool + { + return $comment->user_id === $user->id; + } + + public function delete(User $user, Comment $comment, Channel $channel): bool + { + return $comment->user_id === $user->id || $channel->isOwner($user); + } +} \ No newline at end of file diff --git a/backend/app/Policies/InvitationPolicy.php b/backend/app/Policies/InvitationPolicy.php new file mode 100644 index 0000000..8f9b6e4 --- /dev/null +++ b/backend/app/Policies/InvitationPolicy.php @@ -0,0 +1,51 @@ +isOwner($user); + } + public function create(User $user, Channel $channel): bool + { + return $channel->isOwner($user); + } + + public function accept(User $user, Invitation $invitation): Response + { + if ($invitation->status !== 'pending') { + return Response::deny('This invitation is no longer pending.'); + } + + return $user->id === $invitation->invited_id + ? Response::allow() + : Response::deny('You are not the invited user for this invitation.'); + } + + public function decline(User $user, Invitation $invitation): Response + { + if ($invitation->status !== 'pending') { + return Response::deny('This invitation is no longer pending.'); + } + + return $user->id === $invitation->invited_id + ? Response::allow() + : Response::deny('You are not the invited user for this invitation.'); + } + + public function cancel(User $user, Invitation $invitation): Response + { + if ($invitation->status !== 'pending') { + return Response::deny('Only pending invitations can be cancelled.'); + } + + return $user->id === $invitation->invited_by_id + ? Response::allow() + : Response::deny('You are not allowed to cancel this invitation.'); + } +} diff --git a/backend/app/Policies/SharedDayPolicy.php b/backend/app/Policies/SharedDayPolicy.php new file mode 100644 index 0000000..c209967 --- /dev/null +++ b/backend/app/Policies/SharedDayPolicy.php @@ -0,0 +1,32 @@ +isOwnerOrMember($user); + } + + public function view(User $user, Channel $channel): bool + { + return $channel->isOwnerOrMember($user); + } + public function create(User $user, Channel $channel): bool + { + return $channel->isOwner($user) ; + } + + public function delete(User $user, SharedDay $sharedDay): bool + { + return $user->id === $sharedDay->user_id; + } + + +} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 452e6b6..c4362b2 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -3,7 +3,10 @@ namespace App\Providers; use Illuminate\Support\ServiceProvider; - +use App\Models\Invitation; +use App\Models\TimeEntry; +use App\Observers\InvitationObserver; +use App\Observers\TimeEntryObserver; class AppServiceProvider extends ServiceProvider { /** @@ -19,6 +22,7 @@ public function register(): void */ public function boot(): void { - // + TimeEntry::observe(TimeEntryObserver::class); + Invitation::observe(InvitationObserver::class); } } diff --git a/backend/app/Providers/EventServiceProvider.php b/backend/app/Providers/EventServiceProvider.php index 2d65aac..e027396 100644 --- a/backend/app/Providers/EventServiceProvider.php +++ b/backend/app/Providers/EventServiceProvider.php @@ -18,6 +18,10 @@ class EventServiceProvider extends ServiceProvider Registered::class => [ SendEmailVerificationNotification::class, ], + \SocialiteProviders\Manager\SocialiteWasCalled::class => [ + // ... other providers + \SocialiteProviders\Google\GoogleExtendSocialite::class.'@handle', + ], ]; /** diff --git a/backend/app/Providers/TelescopeServiceProvider.php b/backend/app/Providers/TelescopeServiceProvider.php new file mode 100644 index 0000000..4de2a6b --- /dev/null +++ b/backend/app/Providers/TelescopeServiceProvider.php @@ -0,0 +1,65 @@ +hideSensitiveRequestDetails(); + + $isLocal = $this->app->environment('local'); + + Telescope::filter(function (IncomingEntry $entry) use ($isLocal) { + return $isLocal || + $entry->isReportableException() || + $entry->isFailedRequest() || + $entry->isFailedJob() || + $entry->isScheduledTask() || + $entry->hasMonitoredTag(); + }); + } + + /** + * Prevent sensitive request details from being logged by Telescope. + */ + protected function hideSensitiveRequestDetails(): void + { + if ($this->app->environment('local')) { + return; + } + + Telescope::hideRequestParameters(['_token']); + + Telescope::hideRequestHeaders([ + 'cookie', + 'x-csrf-token', + 'x-xsrf-token', + ]); + } + + /** + * Register the Telescope gate. + * + * This gate determines who can access Telescope in non-local environments. + */ + protected function gate(): void + { + Gate::define('viewTelescope', function (User $user) { + return in_array($user->email, [ + // + ]); + }); + } +} diff --git a/backend/app/Services/InvitationStatusService.php b/backend/app/Services/InvitationStatusService.php new file mode 100644 index 0000000..54114a2 --- /dev/null +++ b/backend/app/Services/InvitationStatusService.php @@ -0,0 +1,63 @@ + $this->accept($invitation, $actor), + 'declined' => $this->decline($invitation, $actor), + 'cancelled' => $this->cancel($invitation, $actor), + default => throw new InvalidArgumentException('Invalid status value.'), + }; + } + + public function accept(Invitation $invitation, User $actor): void + { + if ($invitation->status !== 'pending') { + throw new DomainException('Invitation is not pending.'); + } + + DB::transaction(function () use ($invitation, $actor) { + $invitation->update([ + 'status' => 'accepted', + ]); + + Member::firstOrCreate([ + 'user_id' => $actor->id, + 'channel_id' => $invitation->channel_id, + ]); + }); + } + + public function decline(Invitation $invitation, User $actor): void + { + if ($invitation->status !== 'pending') { + throw new DomainException('Invitation is not pending.'); + } + + $invitation->update([ + 'status' => 'declined', + ]); + } + + public function cancel(Invitation $invitation, User $actor): void + { + if ($invitation->status !== 'pending') { + throw new DomainException('Invitation is not pending.'); + } + + $invitation->update([ + 'status' => 'cancelled', + ]); + } +} diff --git a/backend/app/Services/InviteService.php b/backend/app/Services/InviteService.php new file mode 100644 index 0000000..24912da --- /dev/null +++ b/backend/app/Services/InviteService.php @@ -0,0 +1,81 @@ +findUser($identifier); + + if (! $user) { + throw ValidationException::withMessages([ + 'identifier' => ['User not found for the given identifier.'], + ]); + } + + $this->ensureNotOwner($user, $channel); + $this->ensureNotMember($user, $channel); + + return $this->updateOrCreate($user, $channel); + } + + private function findUser(string $identifier): ?User + { + return User::query() + ->where('email', $identifier) + ->orWhere('name', $identifier) + ->first(); + } + + private function ensureNotOwner(User $user, Channel $channel): void + { + if ($channel->isOwner($user)) { + throw ValidationException::withMessages([ + 'identifier' => ['You cannot invite the channel owner.'], + ]); + } + } + + private function ensureNotMember(User $user, Channel $channel): void + { + if ($channel->isMember($user)) { + throw ValidationException::withMessages([ + 'identifier' => ['This user is already a member of this channel.'], + ]); + } + } + + private function updateOrCreate(User $user, Channel $channel): Invitation + { + $existing = Invitation::where('channel_id', $channel->id) + ->where('invited_id', $user->id) + ->first(); + + if ($existing && in_array($existing->status, ['declined', 'cancelled'], true)) { + $existing->update([ + 'status' => 'pending', + ]); + + return $existing->refresh(); + } + + if ($existing) { + throw ValidationException::withMessages([ + 'identifier' => ['User already has a pending invitation to this channel.'], + ]); + } + + return Invitation::create([ + 'channel_id' => $channel->id, + 'invited_id' => $user->id, + 'invited_by_id' => $channel->user_id, + 'status' => 'pending', + ]); + } +} diff --git a/backend/app/Services/ReportService.php b/backend/app/Services/ReportService.php new file mode 100644 index 0000000..49a9a1b --- /dev/null +++ b/backend/app/Services/ReportService.php @@ -0,0 +1,151 @@ +addHours(24), function () use($userId, $from, $to) { + return [ + 'quick_stats' => $this->quickStats($userId, $from, $to), + 'total_time_by_label' => $this->totalTimeByLabel($userId, $from, $to), + 'most_used_labels' => $this->mostUsedLabels($userId, $from, $to), + 'avg_time_per_label' => $this->avgTimePerLabel($userId, $from, $to), + 'avg_labels_per_day' => $this->avgLabelsPerDay($userId, $from, $to), + ]; + + }); + + } + + private function quickStats(int $userId, ?string $from, ?string $to): array { + $stats = TimeEntry::where('user_id', $userId) + ->inRange($from, $to) + ->selectRaw(' + COUNT(*) as total_entries, + SUM(duration_minutes) as total_minutes, + COUNT(DISTINCT DATE(created_at)) as total_days + + ') + ->first(); + // here since I added a compound index "idx_user_created" the sum and date functions + // won't do a full table scan rather it will just scan the rows that satisfy the condition . + + $totalMinutes = $stats->total_minutes ?? 0; + $totalDays = $stats->total_days ?? 1; + $avgMinutesPerDay = $totalDays > 0 ? intdiv($totalMinutes, $totalDays) : 0; + + return [ + 'total_entries' => $stats->total_entries ?? 0, + 'total_hours' => intdiv($totalMinutes, 60), + 'total_minutes' => $totalMinutes % 60, + 'total_days' => $totalDays, + 'avg_hours_per_day' => intdiv($avgMinutesPerDay, 60), + 'avg_minutes_per_day' => $avgMinutesPerDay % 60, + ]; + + } + + + private function totalTimeByLabel(int $userId, ?string $from, ?string $to): array + { + return TimeEntry::where('user_id', $userId) + ->inRange($from, $to) + ->selectRaw(' + label, + SUM(duration_minutes) as total_minutes, + FLOOR(SUM(duration_minutes) / 60) as hours, + MOD(SUM(duration_minutes), 60) as minutes + ') + ->groupBy('label') + ->orderByDesc('total_minutes') + ->get() + ->toArray(); + } + + + + + private function mostUsedLabels(int $userId, ?string $from, ?string $to): array + { + $totalMinutes = TimeEntry::where('user_id', $userId) + ->inRange($from, $to) + ->sum('duration_minutes'); + + $rows = TimeEntry::where('user_id', $userId) + ->inRange($from, $to) + ->selectRaw('label, SUM(duration_minutes) as total_minutes') + ->groupBy('label') + ->orderByDesc('total_minutes') + ->get(); + + $top5 = $rows->take(5); + $others = $rows->skip(5); + + $result = $top5->map(fn($row) => [ + 'label' => $row->label, + 'hours' => intdiv($row->total_minutes, 60), + 'minutes' => $row->total_minutes % 60, + 'percentage' => $totalMinutes > 0 + ? round(($row->total_minutes / $totalMinutes) * 100, 1) + : 0 + ])->toArray(); + + if ($others->isNotEmpty()) { + $othersMinutes = $others->sum('total_minutes'); + $result[] = [ + 'label' => 'Other', + 'hours' => intdiv($othersMinutes, 60), + 'minutes' => $othersMinutes % 60, + 'percentage' => $totalMinutes > 0 + ? round(($othersMinutes / $totalMinutes) * 100, 1) + : 0 + ]; + } + + return $result; + } + + + + + + private function avgTimePerLabel(int $userId, ?string $from, ?string $to): array + { + return TimeEntry::where('user_id', $userId) + ->inRange($from, $to) + ->selectRaw(' + label, + AVG(duration_minutes) as avg_minutes, + FLOOR(AVG(duration_minutes) / 60) as hours, + MOD(AVG(duration_minutes), 60) as minutes + ') + ->groupBy('label') + ->orderByDesc('avg_minutes') + ->get() + ->toArray(); + } + private function avgLabelsPerDay(int $userId, ?string $from, ?string $to): float + { + $result = DB::select(" + SELECT ROUND(AVG(label_count), 1) as avg_labels + FROM ( + SELECT COUNT(DISTINCT label) as label_count + FROM time_entries + WHERE user_id = ? + AND created_at BETWEEN ? AND ? + GROUP BY DATE(created_at) + ) as daily_counts + ", [$userId, $from, $to]); + + return $result[0]->avg_labels ?? 0; + } + + +} diff --git a/backend/app/Services/ServiceReport.php b/backend/app/Services/ServiceReport.php new file mode 100644 index 0000000..f9dc26f --- /dev/null +++ b/backend/app/Services/ServiceReport.php @@ -0,0 +1,9 @@ +=7.1||>=8.0" + }, + "require-dev": { + "ext-simplexml": "*", + "friendsofphp/php-cs-fixer": "^2.17", + "mockery/mockery": "^1.3.3", + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5||9.5" + }, + "suggest": { + "ext-simplexml": "For decoding XML-based responses." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev", + "dev-develop": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\OAuth1\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Corlett", + "email": "bencorlett@me.com", + "homepage": "http://www.webcomm.com.au", + "role": "Developer" + } + ], + "description": "OAuth 1.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "bitbucket", + "identity", + "idp", + "oauth", + "oauth1", + "single sign on", + "trello", + "tumblr", + "twitter" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth1-client/issues", + "source": "https://github.com/thephpleague/oauth1-client/tree/v1.11.0" + }, + "time": "2024-12-10T19:59:05+00:00" + }, { "name": "monolog/monolog", "version": "3.9.0", @@ -2395,6 +2732,125 @@ ], "time": "2024-11-21T10:36:35+00:00" }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.4", @@ -2470,6 +2926,179 @@ ], "time": "2025-08-21T11:53:16+00:00" }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.52", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2adaefc83df2ec548558307690f376dd7d4f4fce", + "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.52" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2026-04-27T07:02:15+00:00" + }, + { + "name": "predis/predis", + "version": "v3.4.2", + "source": { + "type": "git", + "url": "https://github.com/predis/predis.git", + "reference": "2033429520d8997a7815a2485f56abe6d2d0e075" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/predis/predis/zipball/2033429520d8997a7815a2485f56abe6d2d0e075", + "reference": "2033429520d8997a7815a2485f56abe6d2d0e075", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0", + "psr/http-message": "^1.0|^2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.3", + "phpstan/phpstan": "^1.9", + "phpunit/phpcov": "^6.0 || ^8.0", + "phpunit/phpunit": "^8.0 || ~9.4.4" + }, + "suggest": { + "ext-relay": "Faster connection with in-memory caching (>=0.6.2)" + }, + "type": "library", + "autoload": { + "psr-4": { + "Predis\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Till Krüss", + "homepage": "https://till.im", + "role": "Maintainer" + } + ], + "description": "A flexible and feature-complete Redis/Valkey client for PHP.", + "homepage": "http://github.com/predis/predis", + "keywords": [ + "nosql", + "predis", + "redis" + ], + "support": { + "issues": "https://github.com/predis/predis/issues", + "source": "https://github.com/predis/predis/tree/v3.4.2" + }, + "funding": [ + { + "url": "https://github.com/sponsors/tillkruss", + "type": "github" + } + ], + "time": "2026-03-09T20:33:04+00:00" + }, { "name": "psr/clock", "version": "1.0.0", @@ -3159,6 +3788,121 @@ }, "time": "2025-09-04T20:59:21+00:00" }, + { + "name": "socialiteproviders/google", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/SocialiteProviders/Google-Plus.git", + "reference": "1cb8f6fb2c0dd0fc8b34e95f69865663fdf0b401" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SocialiteProviders/Google-Plus/zipball/1cb8f6fb2c0dd0fc8b34e95f69865663fdf0b401", + "reference": "1cb8f6fb2c0dd0fc8b34e95f69865663fdf0b401", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.2 || ^8.0", + "socialiteproviders/manager": "~4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "SocialiteProviders\\Google\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "xstoop", + "email": "myenglishnameisx@gmail.com" + } + ], + "description": "Google OAuth2 Provider for Laravel Socialite", + "support": { + "source": "https://github.com/SocialiteProviders/Google-Plus/tree/4.1.0" + }, + "time": "2020-12-01T23:10:59+00:00" + }, + { + "name": "socialiteproviders/manager", + "version": "v4.8.1", + "source": { + "type": "git", + "url": "https://github.com/SocialiteProviders/Manager.git", + "reference": "8180ec14bef230ec2351cff993d5d2d7ca470ef4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/8180ec14bef230ec2351cff993d5d2d7ca470ef4", + "reference": "8180ec14bef230ec2351cff993d5d2d7ca470ef4", + "shasum": "" + }, + "require": { + "illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0", + "laravel/socialite": "^5.5", + "php": "^8.1" + }, + "require-dev": { + "mockery/mockery": "^1.2", + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "SocialiteProviders\\Manager\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "SocialiteProviders\\Manager\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andy Wendt", + "email": "andy@awendt.com" + }, + { + "name": "Anton Komarev", + "email": "a.komarev@cybercog.su" + }, + { + "name": "Miguel Piedrafita", + "email": "soy@miguelpiedrafita.com" + }, + { + "name": "atymic", + "email": "atymicq@gmail.com", + "homepage": "https://atymic.dev" + } + ], + "description": "Easily add new or override built-in providers in Laravel Socialite.", + "homepage": "https://socialiteproviders.com", + "keywords": [ + "laravel", + "manager", + "oauth", + "providers", + "socialite" + ], + "support": { + "issues": "https://github.com/socialiteproviders/manager/issues", + "source": "https://github.com/socialiteproviders/manager" + }, + "time": "2025-02-24T19:33:30+00:00" + }, { "name": "symfony/console", "version": "v6.4.30", diff --git a/backend/config/app.php b/backend/config/app.php index 9207160..15ed6ea 100644 --- a/backend/config/app.php +++ b/backend/config/app.php @@ -168,6 +168,8 @@ // App\Providers\BroadcastServiceProvider::class, App\Providers\EventServiceProvider::class, App\Providers\RouteServiceProvider::class, + App\Providers\TelescopeServiceProvider::class, + \SocialiteProviders\Manager\ServiceProvider::class ])->toArray(), /* diff --git a/backend/config/services.php b/backend/config/services.php index 0ace530..e34a877 100644 --- a/backend/config/services.php +++ b/backend/config/services.php @@ -31,4 +31,9 @@ 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), ], -]; + 'google' => [ + 'client_id' => env('CLIENT_ID'), + 'client_secret' => env('CLIENT_SECRET'), + 'redirect' => env('REDIRECT_URIS') + ], +]; \ No newline at end of file diff --git a/backend/config/telescope.php b/backend/config/telescope.php new file mode 100644 index 0000000..6250e78 --- /dev/null +++ b/backend/config/telescope.php @@ -0,0 +1,212 @@ + env('TELESCOPE_ENABLED', true), + + /* + |-------------------------------------------------------------------------- + | Telescope Domain + |-------------------------------------------------------------------------- + | + | This is the subdomain where Telescope will be accessible from. If the + | setting is null, Telescope will reside under the same domain as the + | application. Otherwise, this value will be used as the subdomain. + | + */ + + 'domain' => env('TELESCOPE_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | Telescope Path + |-------------------------------------------------------------------------- + | + | This is the URI path where Telescope will be accessible from. Feel free + | to change this path to anything you like. Note that the URI will not + | affect the paths of its internal API that aren't exposed to users. + | + */ + + 'path' => env('TELESCOPE_PATH', 'telescope'), + + /* + |-------------------------------------------------------------------------- + | Telescope Storage Driver + |-------------------------------------------------------------------------- + | + | This configuration options determines the storage driver that will + | be used to store Telescope's data. In addition, you may set any + | custom options as needed by the particular driver you choose. + | + */ + + 'driver' => env('TELESCOPE_DRIVER', 'database'), + + 'storage' => [ + 'database' => [ + 'connection' => env('DB_CONNECTION', 'mysql'), + 'chunk' => 1000, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Telescope Queue + |-------------------------------------------------------------------------- + | + | This configuration options determines the queue connection and queue + | which will be used to process ProcessPendingUpdate jobs. This can + | be changed if you would prefer to use a non-default connection. + | + */ + + 'queue' => [ + 'connection' => env('TELESCOPE_QUEUE_CONNECTION'), + 'queue' => env('TELESCOPE_QUEUE'), + 'delay' => env('TELESCOPE_QUEUE_DELAY', 10), + ], + + /* + |-------------------------------------------------------------------------- + | Telescope Route Middleware + |-------------------------------------------------------------------------- + | + | These middleware will be assigned to every Telescope route, giving you + | the chance to add your own middleware to this list or change any of + | the existing middleware. Or, you can simply stick with this list. + | + */ + + 'middleware' => [ + 'web', + Authorize::class, + ], + + /* + |-------------------------------------------------------------------------- + | Allowed / Ignored Paths & Commands + |-------------------------------------------------------------------------- + | + | The following array lists the URI paths and Artisan commands that will + | not be watched by Telescope. In addition to this list, some Laravel + | commands, like migrations and queue commands, are always ignored. + | + */ + + 'only_paths' => [ + // 'api/*' + ], + + 'ignore_paths' => [ + 'livewire*', + 'nova-api*', + 'pulse*', + '_boost*', + '.well-known*', + ], + + 'ignore_commands' => [ + // + ], + + /* + |-------------------------------------------------------------------------- + | Telescope Watchers + |-------------------------------------------------------------------------- + | + | The following array lists the "watchers" that will be registered with + | Telescope. The watchers gather the application's profile data when + | a request or task is executed. Feel free to customize this list. + | + */ + + 'watchers' => [ + Watchers\BatchWatcher::class => env('TELESCOPE_BATCH_WATCHER', true), + + Watchers\CacheWatcher::class => [ + 'enabled' => env('TELESCOPE_CACHE_WATCHER', true), + 'hidden' => [], + 'ignore' => [], + ], + + Watchers\ClientRequestWatcher::class => [ + 'enabled' => env('TELESCOPE_CLIENT_REQUEST_WATCHER', true), + 'ignore_hosts' => [], + ], + + Watchers\CommandWatcher::class => [ + 'enabled' => env('TELESCOPE_COMMAND_WATCHER', true), + 'ignore' => [], + ], + + Watchers\DumpWatcher::class => [ + 'enabled' => env('TELESCOPE_DUMP_WATCHER', true), + 'always' => env('TELESCOPE_DUMP_WATCHER_ALWAYS', false), + ], + + Watchers\EventWatcher::class => [ + 'enabled' => env('TELESCOPE_EVENT_WATCHER', true), + 'ignore' => [], + ], + + Watchers\ExceptionWatcher::class => env('TELESCOPE_EXCEPTION_WATCHER', true), + + Watchers\GateWatcher::class => [ + 'enabled' => env('TELESCOPE_GATE_WATCHER', true), + 'ignore_abilities' => [], + 'ignore_packages' => true, + 'ignore_paths' => [], + ], + + Watchers\JobWatcher::class => env('TELESCOPE_JOB_WATCHER', true), + + Watchers\LogWatcher::class => [ + 'enabled' => env('TELESCOPE_LOG_WATCHER', true), + 'level' => 'error', + ], + + Watchers\MailWatcher::class => env('TELESCOPE_MAIL_WATCHER', true), + + Watchers\ModelWatcher::class => [ + 'enabled' => env('TELESCOPE_MODEL_WATCHER', true), + 'events' => ['eloquent.*'], + 'hydrations' => true, + ], + + Watchers\NotificationWatcher::class => env('TELESCOPE_NOTIFICATION_WATCHER', true), + + Watchers\QueryWatcher::class => [ + 'enabled' => env('TELESCOPE_QUERY_WATCHER', true), + 'ignore_packages' => true, + 'ignore_paths' => [], + 'slow' => 100, + ], + + Watchers\RedisWatcher::class => env('TELESCOPE_REDIS_WATCHER', true), + + Watchers\RequestWatcher::class => [ + 'enabled' => env('TELESCOPE_REQUEST_WATCHER', true), + 'size_limit' => env('TELESCOPE_RESPONSE_SIZE_LIMIT', 64), + 'ignore_http_methods' => [], + 'ignore_status_codes' => [], + ], + + Watchers\ScheduleWatcher::class => env('TELESCOPE_SCHEDULE_WATCHER', true), + Watchers\ViewWatcher::class => env('TELESCOPE_VIEW_WATCHER', true), + ], +]; diff --git a/backend/database/factories/ChannelFactory.php b/backend/database/factories/ChannelFactory.php new file mode 100644 index 0000000..a96176c --- /dev/null +++ b/backend/database/factories/ChannelFactory.php @@ -0,0 +1,24 @@ + + */ +class ChannelFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + "name" => fake()->name, + "description" => fake()->sentence, + ]; + } +} diff --git a/backend/database/factories/TimeEntryFactory.php b/backend/database/factories/TimeEntryFactory.php index f9cc77a..258eaa6 100644 --- a/backend/database/factories/TimeEntryFactory.php +++ b/backend/database/factories/TimeEntryFactory.php @@ -21,6 +21,7 @@ public function definition(): array "start_time" => fake()->time(), "end_time" => fake()->time(), 'created_at' => fake()->dateTimeBetween('-5 days'), + // ]; } diff --git a/backend/database/migrations/2018_08_08_100000_create_telescope_entries_table.php b/backend/database/migrations/2018_08_08_100000_create_telescope_entries_table.php new file mode 100644 index 0000000..031b6f4 --- /dev/null +++ b/backend/database/migrations/2018_08_08_100000_create_telescope_entries_table.php @@ -0,0 +1,70 @@ +getConnection()); + + $schema->create('telescope_entries', function (Blueprint $table) { + $table->bigIncrements('sequence'); + $table->uuid('uuid'); + $table->uuid('batch_id'); + $table->string('family_hash')->nullable(); + $table->boolean('should_display_on_index')->default(true); + $table->string('type', 20); + $table->longText('content'); + $table->dateTime('created_at')->nullable(); + + $table->unique('uuid'); + $table->index('batch_id'); + $table->index('family_hash'); + $table->index('created_at'); + $table->index(['type', 'should_display_on_index']); + }); + + $schema->create('telescope_entries_tags', function (Blueprint $table) { + $table->uuid('entry_uuid'); + $table->string('tag'); + + $table->primary(['entry_uuid', 'tag']); + $table->index('tag'); + + $table->foreign('entry_uuid') + ->references('uuid') + ->on('telescope_entries') + ->cascadeOnDelete(); + }); + + $schema->create('telescope_monitoring', function (Blueprint $table) { + $table->string('tag')->primary(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $schema = Schema::connection($this->getConnection()); + + $schema->dropIfExists('telescope_entries_tags'); + $schema->dropIfExists('telescope_entries'); + $schema->dropIfExists('telescope_monitoring'); + } +}; diff --git a/backend/database/migrations/2026_02_27_201248_create_channels_table.php b/backend/database/migrations/2026_02_27_201248_create_channels_table.php new file mode 100644 index 0000000..6a907db --- /dev/null +++ b/backend/database/migrations/2026_02_27_201248_create_channels_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignIdFor(User::class); + $table->string('name'); + $table->text('description')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('channels'); + } +}; diff --git a/backend/database/migrations/2026_02_27_201308_create_members_table.php b/backend/database/migrations/2026_02_27_201308_create_members_table.php new file mode 100644 index 0000000..87b4927 --- /dev/null +++ b/backend/database/migrations/2026_02_27_201308_create_members_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignIdFor(User::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(Channel::class)->constrained()->cascadeOnDelete(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('members'); + } +}; diff --git a/backend/database/migrations/2026_02_27_201329_create_invitations_table.php b/backend/database/migrations/2026_02_27_201329_create_invitations_table.php new file mode 100644 index 0000000..db83faa --- /dev/null +++ b/backend/database/migrations/2026_02_27_201329_create_invitations_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignIdFor(Channel::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(User::class, 'invited_id')->constrained('users')->cascadeOnDelete(); + $table->foreignIdFor(User::class, 'invited_by_id')->constrained('users')->cascadeOnDelete(); + $table->enum('status', ['pending', 'accepted', 'declined', 'cancelled'])->default('pending'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('invitations'); + } +}; diff --git a/backend/database/migrations/2026_02_27_201625_create_shared_days_table.php b/backend/database/migrations/2026_02_27_201625_create_shared_days_table.php new file mode 100644 index 0000000..6861ff5 --- /dev/null +++ b/backend/database/migrations/2026_02_27_201625_create_shared_days_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignIdFor(Channel::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(User::class); + $table->date('date'); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('shared_days'); + } +}; diff --git a/backend/database/migrations/2026_02_27_201637_create_shared_day_entries_table.php b/backend/database/migrations/2026_02_27_201637_create_shared_day_entries_table.php new file mode 100644 index 0000000..5256543 --- /dev/null +++ b/backend/database/migrations/2026_02_27_201637_create_shared_day_entries_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignIdFor(SharedDay::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(TimeEntry::class)->constrained()->cascadeOnDelete(); + $table->unique(['shared_day_id', 'time_entry_id']); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('shared_day_entries'); + } +}; diff --git a/backend/database/migrations/2026_02_27_201648_create_comments_table.php b/backend/database/migrations/2026_02_27_201648_create_comments_table.php new file mode 100644 index 0000000..6af3dd6 --- /dev/null +++ b/backend/database/migrations/2026_02_27_201648_create_comments_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignIdFor(SharedDay::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(User::class); + $table->text('body'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('comments'); + } +}; diff --git a/backend/database/migrations/2026_03_01_232740_create_invitation_logs_table.php b/backend/database/migrations/2026_03_01_232740_create_invitation_logs_table.php new file mode 100644 index 0000000..79d81a0 --- /dev/null +++ b/backend/database/migrations/2026_03_01_232740_create_invitation_logs_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignIdFor(Invitation::class); + $table->foreignIdFor(User::class)->constrained()->cascadeOnDelete(); + $table->enum('action', ['sent', 'accepted', 'declined', 'cancelled'])->default('sent'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('invitation_logs'); + } +}; diff --git a/backend/database/migrations/2026_03_11_212218_add_unique_constraint_to_shared_days_table.php b/backend/database/migrations/2026_03_11_212218_add_unique_constraint_to_shared_days_table.php new file mode 100644 index 0000000..3bbdcff --- /dev/null +++ b/backend/database/migrations/2026_03_11_212218_add_unique_constraint_to_shared_days_table.php @@ -0,0 +1,27 @@ +unique(['channel_id', 'user_id', 'date'], 'unique_channel_user_date'); + $table->index('user_id', 'idx_user_id'); + }); + } + + public function down(): void + { + Schema::table('shared_days', function (Blueprint $table) { + $table->dropUnique('unique_channel_user_date'); + $table->dropIndex('idx_user_id'); + }); +} +}; diff --git a/backend/database/migrations/2026_03_20_200524_add_duration_minutes_to_time_entries_table.php b/backend/database/migrations/2026_03_20_200524_add_duration_minutes_to_time_entries_table.php new file mode 100644 index 0000000..813affb --- /dev/null +++ b/backend/database/migrations/2026_03_20_200524_add_duration_minutes_to_time_entries_table.php @@ -0,0 +1,25 @@ +unsignedInteger('duration_minutes')->default(0); + }); + } + + public function down(): void + { + Schema::table('time_entries', function (Blueprint $table) { + $table->dropColumn('duration_minutes'); + }); + } +}; diff --git a/backend/database/migrations/2026_04_21_184309_add_index_to_time_entries.php b/backend/database/migrations/2026_04_21_184309_add_index_to_time_entries.php new file mode 100644 index 0000000..e2ffe4f --- /dev/null +++ b/backend/database/migrations/2026_04_21_184309_add_index_to_time_entries.php @@ -0,0 +1,25 @@ +index('created_at', 'idx_created_at'); + $table->index(['user_id', 'created_at'],'idx_user_created' ); + $table->index(['label', 'user_id'], 'idx_user_label'); + }); + } + public function down(): void + { + Schema::table('time_entries', function (Blueprint $table) { + $table->dropIndex('idx_created_at'); + $table->dropIndex('idx_user_created'); + $table->dropIndex('idx_user_label'); + }); + } +}; \ No newline at end of file diff --git a/backend/database/migrations/2026_04_22_205944_add_index_to_channels.php b/backend/database/migrations/2026_04_22_205944_add_index_to_channels.php new file mode 100644 index 0000000..735485b --- /dev/null +++ b/backend/database/migrations/2026_04_22_205944_add_index_to_channels.php @@ -0,0 +1,30 @@ +index('user_id', 'idx_user_id'); + $table->index('created_at', 'idx_created_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('channels', function (Blueprint $table) { + $table->dropIndex('idx_user_id'); + $table->dropIndex('idx_created_at'); + }); + } +}; diff --git a/backend/database/migrations/2026_04_23_183815_add_compound_indices.php b/backend/database/migrations/2026_04_23_183815_add_compound_indices.php new file mode 100644 index 0000000..02e661b --- /dev/null +++ b/backend/database/migrations/2026_04_23_183815_add_compound_indices.php @@ -0,0 +1,30 @@ +unique(['user_id', 'channel_id'], 'unique_user_channel'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('members', function (Blueprint $table) { + $table->dropForeign(['user_id']); + $table->dropUnique('unique_user_channel'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + }); + } +}; \ No newline at end of file diff --git a/backend/database/migrations/2026_05_04_163525_add_google_fields_to_users_table.php b/backend/database/migrations/2026_05_04_163525_add_google_fields_to_users_table.php new file mode 100644 index 0000000..67c9056 --- /dev/null +++ b/backend/database/migrations/2026_05_04_163525_add_google_fields_to_users_table.php @@ -0,0 +1,33 @@ +string('google_id')->nullable()->unique()->after('id'); + $table->string('avatar')->nullable()->after('google_id'); + $table->string('password')->nullable()->change(); // ← make nullable + // + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['google_id', 'avatar']); + $table->string('password')->nullable(false)->change(); + // + }); + } +}; diff --git a/backend/database/seeders/ChannelSeeder.php b/backend/database/seeders/ChannelSeeder.php new file mode 100644 index 0000000..0f4d900 --- /dev/null +++ b/backend/database/seeders/ChannelSeeder.php @@ -0,0 +1,21 @@ +create([ + 'user_id' => fn() => $users->random()->id + ]); +} +} diff --git a/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php index dc4eb0d..144a15b 100644 --- a/backend/database/seeders/DatabaseSeeder.php +++ b/backend/database/seeders/DatabaseSeeder.php @@ -3,8 +3,7 @@ namespace Database\Seeders; use Illuminate\Database\Seeder; -use App\Models\User; -use App\Models\TimeEntry; +use App\Models\{TimeEntry, User}; class DatabaseSeeder extends Seeder { @@ -15,5 +14,9 @@ public function run(): void { $this->call(UserSeeder::class); $this->call(TimeEntrySeeder::class); + $this->call(ChannelSeeder::class); + $this->call(MemberSeeder::class); + $this->call(InvitationSeeder::class); + } -} \ No newline at end of file +} diff --git a/backend/database/seeders/InvitationSeeder.php b/backend/database/seeders/InvitationSeeder.php new file mode 100644 index 0000000..7d8ad43 --- /dev/null +++ b/backend/database/seeders/InvitationSeeder.php @@ -0,0 +1,58 @@ +each(function (Channel $channel) { + // Pick some users that are not the channel owner + $users = User::where('id', '!=', $channel->user_id) + ->inRandomOrder() + ->take(3) + ->get(); + + $users->each(function (User $user) use ($channel) { + $invitation = Invitation::where('channel_id', $channel->id) + ->where('invited_id', $user->id) + ->first(); + + if ($invitation) { + // If already a member (accepted), do nothing + if ($invitation->status === 'accepted') { + return; + } + + // If declined or cancelled (or any non-pending, non-accepted future state), + // reset back to pending instead of creating a new row. + if ($invitation->status !== 'pending') { + $invitation->update([ + 'status' => 'pending', + ]); + } + + return; + } + + // No existing invitation: create a new pending one + Invitation::create([ + 'channel_id' => $channel->id, + 'invited_id' => $user->id, + 'invited_by_id' => $channel->user_id, + 'status' => 'pending', + ]); + }); + }); + } +} diff --git a/backend/database/seeders/MemberSeeder.php b/backend/database/seeders/MemberSeeder.php new file mode 100644 index 0000000..cc14149 --- /dev/null +++ b/backend/database/seeders/MemberSeeder.php @@ -0,0 +1,32 @@ +each(function($channel) { + $users = User::where('id', '!=', $channel->user_id) + ->inRandomOrder() + ->take(3) + ->get(); + + $users->each(function($user) use ($channel) { + Member::firstOrCreate([ + 'user_id' => $user->id, + 'channel_id' => $channel->id + ]); + }); + }); +} +} diff --git a/backend/routes/api.php b/backend/routes/api.php index 304d0cb..fec87cc 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -1,40 +1,56 @@ group(function () { + Route::post('/register', 'store'); + Route::post('/login', 'login'); + Route::post('/auth/google', 'google'); +}); Route::middleware('auth:sanctum')->get('/user', function (Request $request) { return $request->user(); }); +Route::middleware('auth:sanctum')->group(function () { + Route::post('/logout', [AuthController::class, 'logout']); -Route::middleware('guest')->controller(AuthController::class)->group(function () { - Route::post('/register', 'store'); - Route::post('/login', 'login'); -}); + Route::apiResource('users', UserController::class) + ->only(['index']); + Route::apiResource('channels', ChannelController::class); -Route::middleware('auth:sanctum')->group(function () { - Route::post('/logout', [AuthController::class, 'logout']); Route::apiResource('time-entries', TimeEntryController::class); -}); + Route::apiResource('channels.invitations', InvitationController::class) + ->scoped(); + + Route::post('shared-days', [SharedDayController::class, 'store']); + Route::apiResource('channels.shared-days', SharedDayController::class) + ->only(['index', 'show', 'destroy']); + Route::apiResource('shared-days.comments', CommentController::class) + ->only(['index', 'store', 'update', 'destroy']); - + Route::get('reports', [ReportController::class, 'index']); - + Route::prefix('me')->group(function () { + Route::get('shared-days', [MeController::class, 'sharedDays']); + Route::get('invitations', [MeController::class, 'invitations']); + Route::get('channels', [MeController::class, 'channels']); + Route::get('memberships', [MeController::class, 'memberships']); + }); +}); \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ab6a57a..39920ac 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,11 +9,15 @@ "version": "0.0.0", "dependencies": { "axios": "^1.13.4", + "chart.js": "^4.5.1", + "laravel-vue-pagination": "^4.1.3", "pinia": "^3.0.4", "vue": "^3.5.27", + "vue-chartjs": "^5.3.3", "vue-i18n": "^11.2.8", "vue-router": "^4.6.4", - "vue-toastification": "^2.0.0-rc.5" + "vue-toastification": "^2.0.0-rc.5", + "vue3-google-login": "^2.1.3" }, "devDependencies": { "@tailwindcss/vite": "^4.2.0", @@ -1016,6 +1020,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -2062,6 +2072,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -2644,6 +2666,15 @@ "dev": true, "license": "MIT" }, + "node_modules/laravel-vue-pagination": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/laravel-vue-pagination/-/laravel-vue-pagination-4.1.3.tgz", + "integrity": "sha512-KHz3L19ovlcMfRREFgqip+2m+fnYwONHvK3kOkuglfHhI6SkRk5w90DVHfVzms00o3wyMqdS6xo49c2+kcW5gg==", + "license": "MIT", + "dependencies": { + "vue": "^3.2.47" + } + }, "node_modules/lightningcss": { "version": "1.31.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", @@ -3570,6 +3601,16 @@ } } }, + "node_modules/vue-chartjs": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.3.tgz", + "integrity": "sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "vue": "^3.0.0-0 || ^2.7.0" + } + }, "node_modules/vue-i18n": { "version": "11.2.8", "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.8.tgz", @@ -3626,6 +3667,15 @@ "vue": "^3.0.2" } }, + "node_modules/vue3-google-login": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/vue3-google-login/-/vue3-google-login-2.1.3.tgz", + "integrity": "sha512-K5pw/AhsKGaixsZ2ulOI4ERVFl0r0vSzMjsjVd2zitzWPVjr80X1Dt1lT33AaPzOFXdWFpnkM5QWmOR9REHq0w==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.0.3" + } + }, "node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index a6b0ad1..f61e749 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,11 +10,15 @@ }, "dependencies": { "axios": "^1.13.4", + "chart.js": "^4.5.1", + "laravel-vue-pagination": "^4.1.3", "pinia": "^3.0.4", "vue": "^3.5.27", + "vue-chartjs": "^5.3.3", "vue-i18n": "^11.2.8", "vue-router": "^4.6.4", - "vue-toastification": "^2.0.0-rc.5" + "vue-toastification": "^2.0.0-rc.5", + "vue3-google-login": "^2.1.3" }, "devDependencies": { "@tailwindcss/vite": "^4.2.0", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 2002f15..fde9cb9 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,39 +1,93 @@ -```vue + + \ No newline at end of file diff --git a/frontend/src/components/AppPagination.vue b/frontend/src/components/AppPagination.vue new file mode 100644 index 0000000..2adabee --- /dev/null +++ b/frontend/src/components/AppPagination.vue @@ -0,0 +1,18 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/BarChart.vue b/frontend/src/components/BarChart.vue deleted file mode 100644 index 32a16b9..0000000 --- a/frontend/src/components/BarChart.vue +++ /dev/null @@ -1,53 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/src/components/GoogleLogin.vue b/frontend/src/components/GoogleLogin.vue new file mode 100644 index 0000000..c2ef20a --- /dev/null +++ b/frontend/src/components/GoogleLogin.vue @@ -0,0 +1,52 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/ListView.vue b/frontend/src/components/ListView.vue deleted file mode 100644 index 8c0a167..0000000 --- a/frontend/src/components/ListView.vue +++ /dev/null @@ -1,27 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/src/components/NavBar.vue b/frontend/src/components/NavBar.vue index cac8647..0972f15 100644 --- a/frontend/src/components/NavBar.vue +++ b/frontend/src/components/NavBar.vue @@ -1,40 +1,71 @@ - - \ No newline at end of file + await authStore.logout() +} + + + \ No newline at end of file diff --git a/frontend/src/components/channel/ChannelCard.vue b/frontend/src/components/channel/ChannelCard.vue new file mode 100644 index 0000000..9beb9bc --- /dev/null +++ b/frontend/src/components/channel/ChannelCard.vue @@ -0,0 +1,48 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/channel/ChannelOverview.vue b/frontend/src/components/channel/ChannelOverview.vue new file mode 100644 index 0000000..9a4470d --- /dev/null +++ b/frontend/src/components/channel/ChannelOverview.vue @@ -0,0 +1,65 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/channel/ChannelSettings.vue b/frontend/src/components/channel/ChannelSettings.vue new file mode 100644 index 0000000..9c1fa7f --- /dev/null +++ b/frontend/src/components/channel/ChannelSettings.vue @@ -0,0 +1,57 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/channel/members/ChannelMembers.vue b/frontend/src/components/channel/members/ChannelMembers.vue new file mode 100644 index 0000000..87c718a --- /dev/null +++ b/frontend/src/components/channel/members/ChannelMembers.vue @@ -0,0 +1,99 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/channel/members/InviteMemberModal.vue b/frontend/src/components/channel/members/InviteMemberModal.vue new file mode 100644 index 0000000..eaaf3ef --- /dev/null +++ b/frontend/src/components/channel/members/InviteMemberModal.vue @@ -0,0 +1,117 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/channel/members/PendingInvitationsList.vue b/frontend/src/components/channel/members/PendingInvitationsList.vue new file mode 100644 index 0000000..022530e --- /dev/null +++ b/frontend/src/components/channel/members/PendingInvitationsList.vue @@ -0,0 +1,28 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/channel/shared-days/AddComment.vue b/frontend/src/components/channel/shared-days/AddComment.vue new file mode 100644 index 0000000..e6d3343 --- /dev/null +++ b/frontend/src/components/channel/shared-days/AddComment.vue @@ -0,0 +1,48 @@ + + \ No newline at end of file diff --git a/frontend/src/components/channel/shared-days/CommentsSection.vue b/frontend/src/components/channel/shared-days/CommentsSection.vue new file mode 100644 index 0000000..ca6d7c5 --- /dev/null +++ b/frontend/src/components/channel/shared-days/CommentsSection.vue @@ -0,0 +1,73 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/channel/shared-days/SharedDayCard.vue b/frontend/src/components/channel/shared-days/SharedDayCard.vue new file mode 100644 index 0000000..ead198c --- /dev/null +++ b/frontend/src/components/channel/shared-days/SharedDayCard.vue @@ -0,0 +1,103 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/channel/shared-days/SharedDayDetails.vue b/frontend/src/components/channel/shared-days/SharedDayDetails.vue new file mode 100644 index 0000000..eead234 --- /dev/null +++ b/frontend/src/components/channel/shared-days/SharedDayDetails.vue @@ -0,0 +1,90 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/charts/BarChart.vue b/frontend/src/components/charts/BarChart.vue new file mode 100644 index 0000000..e0de931 --- /dev/null +++ b/frontend/src/components/charts/BarChart.vue @@ -0,0 +1,23 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/charts/DoughnutChart.vue b/frontend/src/components/charts/DoughnutChart.vue new file mode 100644 index 0000000..2d98938 --- /dev/null +++ b/frontend/src/components/charts/DoughnutChart.vue @@ -0,0 +1,23 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/charts/HorizontalBarChart.vue b/frontend/src/components/charts/HorizontalBarChart.vue new file mode 100644 index 0000000..fcd3d53 --- /dev/null +++ b/frontend/src/components/charts/HorizontalBarChart.vue @@ -0,0 +1,24 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/history/HistoryDateItem.vue b/frontend/src/components/history/HistoryDateItem.vue new file mode 100644 index 0000000..c28a075 --- /dev/null +++ b/frontend/src/components/history/HistoryDateItem.vue @@ -0,0 +1,21 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/history/HistorySidebar.vue b/frontend/src/components/history/HistorySidebar.vue new file mode 100644 index 0000000..8ddf52e --- /dev/null +++ b/frontend/src/components/history/HistorySidebar.vue @@ -0,0 +1,55 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/invitations/InvitationCard.vue b/frontend/src/components/invitations/InvitationCard.vue new file mode 100644 index 0000000..95fee2b --- /dev/null +++ b/frontend/src/components/invitations/InvitationCard.vue @@ -0,0 +1,87 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/time-entries/ShareDayModal.vue b/frontend/src/components/time-entries/ShareDayModal.vue new file mode 100644 index 0000000..d5d525f --- /dev/null +++ b/frontend/src/components/time-entries/ShareDayModal.vue @@ -0,0 +1,201 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/time-entries/SingleEntry.vue b/frontend/src/components/time-entries/SingleEntry.vue new file mode 100644 index 0000000..761fc1b --- /dev/null +++ b/frontend/src/components/time-entries/SingleEntry.vue @@ -0,0 +1,129 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/time-entries/TimeEntryList.vue b/frontend/src/components/time-entries/TimeEntryList.vue new file mode 100644 index 0000000..9d272b1 --- /dev/null +++ b/frontend/src/components/time-entries/TimeEntryList.vue @@ -0,0 +1,29 @@ + + + \ No newline at end of file diff --git a/frontend/src/composables/usePagination.js b/frontend/src/composables/usePagination.js new file mode 100644 index 0000000..0c85d9e --- /dev/null +++ b/frontend/src/composables/usePagination.js @@ -0,0 +1,32 @@ +import { ref, computed } from 'vue' +import useApi from './useApi' + +export function usePagination(apiFunc, options = {}) { + const { filters = {}, initialPage = 1, immediate = true } = options + + function resolveFilters() { + return filters?.value ?? filters + // filters could be a ref or a plain object this is way we handle it this way + } + + const paginatorData = ref(null) + //this is the full raw Laravel response object. + + const { loading, error, request } = useApi( + (page) => apiFunc(page, resolveFilters()).then(response => { + paginatorData.value = response.data + return response // ← useApi does result.data on this, so it takes out the pagination attributes which we needs + }), + false + ) + + const items = computed(() => paginatorData.value?.data ?? []) // array of actual data from the res + const isEmpty = computed(() => !loading.value && items.value.length === 0) + + const goToPage = (page) => request(page) + const refresh = () => request(1) + + if (immediate) request(initialPage) + + return { items, paginatorData, loading, error, isEmpty, goToPage, refresh } +} \ No newline at end of file diff --git a/frontend/src/locales/ar.json b/frontend/src/locales/ar.json new file mode 100644 index 0000000..3f6a7d8 --- /dev/null +++ b/frontend/src/locales/ar.json @@ -0,0 +1,227 @@ +{ + "message": { + "hello": "مرحبا", + "hide_history": "اخفاء", + "add_entry": "أضف نشاط جديد" + }, + "login": { + "welcome_back": "أهلا بعودتك", + "email": "الايميل", + "password": "كلمة المرور", + "login": "تسجيل الدخول", + "dont_have_account": "لا تملك حساب ؟", + "sign_up_here": "أنشئ حساب هنا" + }, + "register": { + "create_account": "أنشئ حساب", + "name": "الاسم", + "email": "الايميل", + "password": "كلمة المرور", + "confirm_password": "تأكيد كلمة المرور", + "sign_up": "أنشئ حساب", + "you_already_have_account": "لديك حساب؟", + "login": "سجل الدخول" + }, + "about": { + "title": "عن الموقع", + "p1": "تايم تراكر هو أداة إنتاجية شخصية تساعدك على تسجيل وفهم كيفية قضاء وقتك كل يوم. فقط أضف نشاطاً بعنوان ووقت بداية ونهاية — وسيتولى التطبيق الباقي.", + "p2": "يمكنك مشاركة سجلات نشاطك اليومية مع القنوات — وهي مجموعات من الأشخاص الذين تتعاون معهم. يمكن لأعضاء القناة عرض أيامك المشتركة ونشاطاتك وترك تعليقات. إنها طريقة بسيطة للبقاء شفافاً مع فريقك.", + "p3": "تمنحك ميزة التقارير صورة واضحة عن أين يذهب وقتك. اختر أي نطاق زمني وشاهد فوراً تحليلاً مفصلاً حسب النشاط ومتوسطات يومية — كل ذلك مرئي بمخططات بيانية لتسهيل اكتشاف الأنماط." + }, + "nav": { + "home": "الصفحة الرئيسية", + "about": "عن الموقع", + "add": "أضف نشاط", + "sign_out": "تسجيل الخروج", + "channels": "القنوات", + "shared_days": "الأيام المشتركة", + "reports": "التقارير", + "invitations": "الدعوات" + }, + "addForm": { + "label": "العنوان", + "start_time": "وقت البدء", + "end_time": "وقت الانتهاء", + "add_entry": "أضف النشاط" + }, + "home": { + "history": "السجل", + "hide": "إخفاء", + "add_new_entry": "أضف نشاط جديد", + "today_entries": "نشاطات اليوم", + "entries_of": "نشاطات", + "error": "عذراً! حدث خطأ", + "total_time": "إجمالي الوقت", + "total_entries": "النشاطات", + "avg_per_entry": "المتوسط لكل نشاط", + "load_more": "تحميل المزيد", + "loading": "جاري التحميل...", + "error_loading_dates": "خطأ في تحميل التواريخ", + "no_dates": "لا توجد تواريخ متاحة", + "search_placeholder": "بحث...", + "sort_default": "افتراضي", + "sort_oldest": "الأقدم", + "sort_latest": "الأحدث" + }, + "single_entry": { + "hour": "صفر ساعات | {n} ساعة | {n} ساعات", + "minute": "صفر دقائق | {n} دقيقة | {n} دقائق", + "confirm_delete": "هل أنت متأكد؟", + "yes": "نعم", + "no": "لا" + }, +"update": { + "label": "العنوان", + "start_time": "وقت البدء", + "end_time": "وقت الانتهاء", + "loading": "جاري التحديث...", + "update": "تحديث", + "save_changes": "حفظ التغييرات", + "cancel": "إلغاء" + }, + "channel_list": { + "title": "القنوات", + "new_channel": "قناة جديدة", + "my_channels": "قنواتي", + "member_of": "عضو في", + "no_owned": "ليس لديك أي قنوات بعد.", + "no_joined": "لست عضواً في أي قناة بعد.", + "loading": "جاري التحميل...", + "error": "حدث خطأ ما", + "delete_confirm": "هل أنت متأكد أنك تريد حذف هذه القناة؟", + "create_first": "أنشئ قناتك الأولى" + }, + "create_channel": { + "title": "إنشاء قناة", + "name": "الاسم", + "description": "الوصف", + "submit": "إنشاء القناة", + "loading": "جاري التحميل..." + }, + "channel": { + "overview": "نظرة عامة", + "members": "الأعضاء", + "shared_days": "الأيام المشتركة", + "settings": "الإعدادات", + "coming_soon": "قريباً...", + "delete_channel": "حذف القناة", + "delete_warning": "هذا الإجراء دائم ولا يمكن التراجع عنه.", + "delete_confirm": "هل أنت متأكد أنك تريد حذف هذه القناة؟", + "yes": "نعم، احذف", + "no": "إلغاء", + "loading": "جاري التحميل...", + "error": "حدث خطأ ما" + }, + "channel_members": { + "community": "المجتمع", + "manage_members": "إدارة أعضاء القناة والدعوات الصادرة.", + "active_members": "الأعضاء النشطون", + "pending_invitations": "الدعوات المعلقة", + "invite_member": "دعوة عضو", + "no_pending": "لا توجد دعوات معلقة." + }, + "invite_modal": { + "title": "دعوة إلى القناة", + "search_placeholder": "ابحث بالاسم أو البريد الإلكتروني", + "cancel": "إلغاء", + "send": "إرسال الدعوة", + "sending": "جاري الإرسال..." + }, + "channel_overview": { + "created": "تم الإنشاء", + "members": "الأعضاء", + "no_description": "لا يوجد وصف لهذه القناة." + }, + "channel_card": { + "no_description": "لا يوجد وصف لهذه القناة.", + "members": "أعضاء", + "enter": "دخول" + }, + "invitation_card": { + "cancel": "إلغاء", + "accept": "قبول", + "decline": "رفض" + }, + "invitations_view": { + "title": "دعواتي", + "empty": "لم تتلقَ أي دعوات بعد.", + "loading": "جاري التحميل...", + "error": "حدث خطأ ما" + }, + "share_day": { + "title": "مشاركة النشاط", + "entries_title": "اختر النشاطات التي تريد مشاركتها", + "channels_title": "شارك إلى القنوات", + "loading_entries": "جاري تحميل النشاطات...", + "loading_channels": "جاري تحميل القنوات...", + "error_entries": "فشل تحميل النشاطات. يرجى المحاولة مجدداً.", + "error_channels": "فشل تحميل القنوات. يرجى المحاولة مجدداً.", + "no_entries": "لا توجد نشاطات لهذا اليوم.", + "no_channels": "لا تمتلك أي قنوات بعد.", + "members": "أعضاء", + "sharing": "جاري المشاركة...", + "submit": "شارك اليوم", + "selected": "محدد", + "ready": "جاهز للمشاركة؟", + "summary": "{entries} نشاط إلى {channels} قنوات" + }, + "shared_day_card": { + "day": "يوم", + "entries": "نشاطات" + }, + "my_shared_days": { + "title": "أيامي المشتركة", + "shared_to": "تمت المشاركة مع {count} قناة | تمت المشاركة مع {count} قنوات", + "empty_title": "لا توجد أيام مشتركة بعد", + "empty_message": "اذهب إلى سجلك وشارك يوماً مع قناة." + }, + "channel_shared_days": { + "removed": "تمت إزالة اليوم بنجاح!", + "empty_title": "لا توجد أيام مشتركة بعد", + "empty_message": "ستظهر السجلات المشتركة هنا بمجرد نشرها." + }, + "shared_day_view": { + "summary": "ملخص سجل النشاط", + "total_time": "إجمالي الوقت" + }, + "history_date_item": { + "share": "مشاركة" + }, + + "comments": { + "save": "حفظ", + "cancel": "إلغاء", + "placeholder": "اكتب تعليقاً...", + "submit": "نشر", + "submitting": "جاري النشر...", + "title": "التعليقات", + "empty": "لا توجد تعليقات بعد. كن أول من يعلق!", + "load_more": "تحميل المزيد", + "show": "إظهار التعليقات", + "hide": "إخفاء التعليقات" + +}, +"reports": { + "title": "إحصائيات القناة", + "subtitle": "نظرة مفصلة على نشاط مساحة العمل الخاصة بك.", + "refresh": "تحديث", + "generate": "إنشاء التقرير", + "generating": "جاري الإنشاء...", + "from": "من", + "to": "إلى", + "empty_state": "لم يتم العثور على مدخلات", + "empty_hint": "جرّب اختيار نطاق زمني مختلف.", + "quick_stats": { + "title": "إحصائيات سريعة", + "total_entries": "إجمالي المدخلات", + "total_time": "إجمالي الوقت", + "daily_avg": "المعدل اليومي", + "labels_per_day": "تصنيفات يومياً" + }, + "charts": { + "time_distribution": "توزيع الوقت", + "most_used": "التصنيفات الأكثر استخداماً", + "avg_per_label": "متوسط الوقت لكل تصنيف" + } + } +} \ No newline at end of file diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json new file mode 100644 index 0000000..1411d86 --- /dev/null +++ b/frontend/src/locales/en.json @@ -0,0 +1,226 @@ +{ + "message": { + "hello": "hello" + }, + "login": { + "welcome_back": "Welcome Back", + "email": "Email", + "password": "Password", + "login": "Login", + "dont_have_account": "Don't have an account?", + "sign_up_here": "Sign Up Here" + }, + "register": { + "create_account": "Create Account", + "name": "Name", + "email": "Email", + "password": "Password", + "confirm_password": "Confirm Password", + "sign_up": "Sign Up", + "you_already_have_account": "You already have an account? ", + "login": "Login" + }, + "about": { + "title": "About", + "p1": "TimeTracker is a personal productivity tool that helps you log and understand how you spend your time every day. Simply add time entries with a label, start time, and end time — and the app takes care of the rest.", + "p2": "You can share your daily activity logs with channels — groups of people you collaborate with. Channel members can view your shared days, see your time entries, and leave comments. It's a simple way to stay transparent with your team without the overhead of complex project management tools.", + "p3": "The reports feature gives you a clear picture of where your time goes. Select any date range and instantly see breakdowns by label, your most used activity types, and daily averages — all visualized with charts to make patterns easy to spot." + }, + + "nav": { + "home": "Home", + "about": "About", + "add": "Add Entry", + "sign_out": "Sign Out", + "channels": "Channels", + "shared_days": "Shared Days", + "reports": "Reports", + "invitations": "Invitations" + }, + "addForm": { + "label": "Label", + "start_time": "Start Time", + "end_time": "End Time", + "add_entry": "Add Entry" + }, + "home": { + "history": "History", + "hide": "Hide", + "total_time": "Total Time", + "total_entries": "Entries", + "avg_per_entry": "Avg per Entry", + "load_more": "Load more", + "add_new_entry": "Add New Entry", + "today_entries": "Today's Entries", + "entries_of": "Time Entries of", + "error": "Oops! Error encountered", + "loading": "Loading entries...", + "error_loading_dates": "Error loading dates", + "no_dates": "No dates available", + "search_placeholder": "Search...", + "sort_default": "Default", + "sort_oldest": "Oldest", + "sort_latest": "Latest" + }, + "single_entry": { + "hour": "0 hours | {n} hour | {n} hours", + "minute": "0 minutes | {n} minute | {n} minutes", + "confirm_delete": "Are you sure?", + "yes": "Yes", + "no": "No" + }, + "update": { + "label": "Label", + "start_time": "Start Time", + "end_time": "End Time", + "loading": "Updating...", + "update": "Update", + "save_changes": "Save Changes", + "cancel": "Cancel" + }, + "channel_list": { + "title": "Channels", + "new_channel": "New Channel", + "my_channels": "My Channels", + "member_of": "Member Of", + "no_owned": "You have no channels yet.", + "no_joined": "You are not a member of any channel yet.", + "loading": "Loading...", + "error": "Something went wrong", + "delete_confirm": "Are you sure you want to delete this channel?", + "create_first": "Create your first channel" + }, + "create_channel": { + "title": "Create Channel", + "name": "Name", + "description": "Description", + "submit": "Create Channel", + "loading": "Loading..." + }, + "channel": { + "overview": "Overview", + "members": "Members", + "shared_days": "Shared Days", + "settings": "Settings", + "coming_soon": "Coming soon...", + "delete_channel": "Delete Channel", + "delete_warning": "This action is permanent and cannot be undone.", + "delete_confirm": "Are you sure you want to delete this channel?", + "yes": "Yes, delete", + "no": "Cancel", + "loading": "Loading...", + "error": "Something went wrong" + }, + "channel_members": { + "community": "Community", + "manage_members": "Manage your channel members and outgoing invites.", + "active_members": "Active Members", + "pending_invitations": "Pending Invitations", + "invite_member": "Invite Member", + "no_pending": "No pending invitations." + }, + "invite_modal": { + "title": "Invite to Channel", + "search_placeholder": "Search by name or email", + "cancel": "Cancel", + "send": "Send Invitation", + "sending": "Sending..." + }, + "channel_overview": { + "created": "Created", + "members": "Members", + "no_description": "No description provided for this channel." + }, + "channel_card": { + "no_description": "No description provided for this channel.", + "members": "members", + "enter": "Enter" + }, + "invitation_card": { + "cancel": "Cancel", + "accept": "Accept", + "decline": "Decline" + }, + "invitations_view": { + "title": "My Invitations", + "empty": "You haven't received any invitations yet.", + "loading": "Loading...", + "error": "Something went wrong" + }, + "share_day": { + "title": "Share Activity", + "entries_title": "Select Time Entries to Share", + "channels_title": "Share To Channels", + "loading_entries": "Loading entries...", + "loading_channels": "Loading channels...", + "error_entries": "Failed to load time entries. Please try again.", + "error_channels": "Failed to load channels. Please try again.", + "no_entries": "No time entries found for this day.", + "no_channels": "You don't own any channels yet.", + "members": "members", + "sharing": "Sharing...", + "submit": "Share Day", + "selected": "selected", + "ready": "Ready to share?", + "summary": "{entries} logs to {channels} channels" + }, + "shared_day_card": { + "day": "Day", + "entries": "entries" + }, + "my_shared_days": { + "title": "My Shared Days", + "shared_to": "Shared to {count} channel | Shared to {count} channels", + "empty_title": "No shared days yet", + "empty_message": "Go to your history and share a day to a channel." + }, + "channel_shared_days": { + "removed": "Day removed successfully!", + "empty_title": "No shared days yet", + "empty_message": "Shared logs will appear here once members post them." + }, + "shared_day_view": { + "summary": "Activity Log Summary", + "total_time": "Total Time" + }, + "history_date_item": { + "share": "Share" + }, + + "comments": { + "save": "Save", + "cancel": "Cancel", + "placeholder": "Write a comment...", + "submit": "Post", + "submitting": "Posting...", + "title": "Comments", + "empty": "No comments yet. Be the first to comment!", + "load_more": "Load more", + "show": "Show Comments", + "hide": "Hide Comments" + +}, +"reports": { + "title": "Channel Insights", + "subtitle": "Detailed overview of your workspace activity.", + "refresh": "Refresh", + "generate": "Generate Report", + "generating": "Generating...", + "from": "From", + "to": "To", + "empty_state": "No entries found", + "empty_hint": "Try selecting a different date range.", + "quick_stats": { + "title": "Quick Stats", + "total_entries": "Total Entries", + "total_time": "Total Time", + "daily_avg": "Daily Average", + "labels_per_day": "labels per day" + }, + "charts": { + "time_distribution": "Time Distribution", + "most_used": "Most Used Labels", + "avg_per_label": "Average Time per Label" + } + } +} \ No newline at end of file diff --git a/frontend/src/main.js b/frontend/src/main.js index 5262432..d710170 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -6,129 +6,24 @@ import router from './router' import './assets/main.css' import Toast from 'vue-toastification' import 'vue-toastification/dist/index.css' +import en from './locales/en.json' +import ar from './locales/ar.json' +import vue3GoogleLogin from 'vue3-google-login' + const app = createApp(App) +const i18n = createI18n({ + legacy: false, + locale: 'ar', + fallbackLocale: 'en', + messages: { en, ar } +}) + app.use(createPinia()) app.use(router) app.use(Toast) - -const i18n = createI18n({ -legacy: false, - locale: 'ar', - fallbackLocale: 'en', - messages: { - en: { - mesasge: { - hello: 'hello', - }, - login: { - welcome_back: 'Welcome Back', - email: 'Email', - password: 'Password', - login: 'Login', - dont_have_account: "Don't have an account?", - sign_up_here: "Sign Up Here" - }, - register:{ - create_account: "Create Account", - name: "Name", - email: "Email", - password: "Password", - confirm_password: "Confirm Password", - sign_up: "Sign Up", - you_already_have_account: "You already have an account? ", - login: "Login" - }, - nav: { - home: "Home", - about: "About", - add: "Add a new Time Entry", - sign_out: "Sign Out", - - }, - addForm: { - label:"Label", - start_time: "Start Time", - end_time: "End Time", - add_entry: "Add Entry" - }, - home: { - history: "History", - hide: "Hide", - add_new_entry: "Add New Entry", - today_entries: "Today's Entries", - entries_of: "Time Entries of", - error: "Oops! Error encountered", - loading: "Loading entries...", - error_loading_dates: "Error loading dates", - no_dates: "No dates available" - }, - single_entry: { - hour: '0 hours | {n} hour | {n} hours', - "confirm_delete": "Are you sure?", - "yes": "Yes", - "no": "No", - minute: '0 minutes | {n} minute | {n} minutes' - } - - }, - ar: { - message: { - hello: "مرحبا", - hide_history: 'اخفاء', - add_entry: 'أضف نشاط جديد' - }, - login: { - welcome_back: 'أهلا بعودتك', - email: 'الايميل', - password: 'كلمة المرور', - login:'تسجيل الدخول', - dont_have_account: "لا تملك حساب ؟", - sign_up_here: "أنشئ حساب هنا" - }, - register:{ - create_account: "أنشئ حساب", - name: "الاسم", - email: "الايميل", - password: "كلمة المرور", - confirm_password: "تأكيد كلمة المرور", - sign_up: "أنشئ حساب", - you_already_have_account: "لديك حساب؟", - login: "سجل الدخول" - }, - nav: { - home: "الصفحة الرئيسية", - about: "عن الموقع", - add: 'أضف نشاط جديد', - sign_out: "تسجيل الخروج", - - }, - addForm: { - label:"العنوان", - start_time: "وقت البدء", - end_time: "وقت الانتهاء", - add_entry: "أضف النشاط" - }, - home: { - history: "السجل", - hide: "إخفاء", - add_new_entry: "أضف نشاط جديد", - today_entries: "نشاطات اليوم", - entries_of: "نشاطات", - error: "عذراً! حدث خطأ", - loading: "جاري التحميل...", - error_loading_dates: "خطأ في تحميل التواريخ", - no_dates: "لا توجد تواريخ متاحة" - }, - single_entry: { - hour: 'صفر ساعات | {n} ساعة | {n} ساعات', - minute: 'صفر دقائق | {n} دقيقة | {n} دقائق', - "confirm_delete": "هل أنت متأكد؟", - "yes": "نعم", - "no": "لا" - } - }, - }, -}); app.use(i18n) -app.mount('#app') +app.use(vue3GoogleLogin, { + clientId: '790034353136-fdm5vgcqgmjsh6lcp4igrc4khenihdes.apps.googleusercontent.com' +}) +app.mount('#app') \ No newline at end of file diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index ec7b1d9..a5d6e1d 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -1,10 +1,17 @@ import About from '@/views/About.vue' -import Home from '@/views/Home.vue' +import HomeView from '@/views/HomeView.vue' import { createRouter, createWebHistory } from 'vue-router' import Login from '@/views/Login.vue' import Register from '@/views/Register.vue' import AddForm from '@/views/AddForm.vue' import { authGaurd } from '@/middleware/auth' +import CreateChannelForm from '@/views/CreateChannelForm.vue' +import ChannelDashboard from '@/views/ChannelDashboard.vue' +import ChannelList from '@/views/ChannelList.vue' +import InvitationsView from '@/views/InvitationsView.vue' +import SharedDayDetails from '@/components/channel/shared-days/SharedDayDetails.vue' +import MySharedDays from '@/views/MySharedDays.vue' +import ReportsView from '@/views/ReportsView.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -12,7 +19,7 @@ const router = createRouter({ { path: "/", name: "Home", - component: Home, + component: HomeView, meta: { requiresAuth: true } }, { @@ -22,11 +29,54 @@ const router = createRouter({ meta: { requiresAuth: true } }, { - path: "/add", - name: "Add", + path: "/add-entry", + name: "AddEntry", component: AddForm, meta: {requiresAuth: true} }, + { + path: "/channels", + name: "Channels", + component: ChannelList, + meta: {requiresAuth: true} + }, + { + path: "/add-channel", + name: "AddChannel", + component: CreateChannelForm, + meta: {requiresAuth: true} + }, + { + path: '/channels/:id', + name: 'Channel', + component: ChannelDashboard, + meta: {requiresAuth: true} + }, + { + path: '/my-shared-days', + name: 'MySharedDays', + component: MySharedDays, + meta: {requiresAuth: true} + + }, + { + path: '/channels/:channel_id/shared-days/:id', + name: 'SharedDay', + component: SharedDayDetails, + meta: {requiresAuth: true} + }, + { + path: '/my-invitations', + name: 'MyInvitations', + component: InvitationsView, + meta: {requiresAuth: true} + }, + { + path: '/reports', + name: 'Reports', + component: ReportsView, + meta: {requiresAuth: true} + }, { path: "/login", name: "Login", diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index a6a200c..24287a4 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -24,27 +24,32 @@ apiClient.interceptors.request.use( ); apiClient.interceptors.response.use( - (response) => response, - (error) => { - let message = ERROR_MESSAGES.GENERIC_ERROR; - if (error.response) { - const { status, data } = error.response; - - if (data && data.message) { - message = data.message; - } else { - message = ERROR_MESSAGES[status] || ERROR_MESSAGES.GENERIC_ERROR; - } - if (status === 401) { - } else { - message = ERROR_MESSAGES.NETWORK_ERROR; - } + (response) => response, + (error) => { + let message = ERROR_MESSAGES.GENERIC_ERROR; + + if (error.response) { + const { status, data } = error.response; - toast.error(message); - return Promise.reject(new Error(message)); - }} + if (data && data.message) { + message = data.message; + } else { + message = ERROR_MESSAGES[status] || ERROR_MESSAGES.GENERIC_ERROR; + } + + if (status === 401) { + // handle 401 here e.g. redirect to login + } + } else { + // only set NETWORK_ERROR if there's no response at all + message = ERROR_MESSAGES.NETWORK_ERROR; + } + + toast.error(message); + return Promise.reject(new Error(message)); + } ); -export default apiClient; \ No newline at end of file +export default apiClient; diff --git a/frontend/src/services/channelService.js b/frontend/src/services/channelService.js new file mode 100644 index 0000000..1607039 --- /dev/null +++ b/frontend/src/services/channelService.js @@ -0,0 +1,15 @@ +import apiClient from "./api"; + +const getChannels = (params) => apiClient.get('/api/channels', {params}) +const createChannel = (params) => apiClient.post('/api/channels', params) +const getChannel = (channelId) => apiClient.get(`/api/channels/${channelId}`) +const deleteChannel = (channelId) => apiClient.delete(`/api/channels/${channelId}`) + +export default { + + getChannels, + createChannel, + getChannel, + deleteChannel + +}; diff --git a/frontend/src/services/commentService.js b/frontend/src/services/commentService.js new file mode 100644 index 0000000..38ed2af --- /dev/null +++ b/frontend/src/services/commentService.js @@ -0,0 +1,16 @@ +// commentService.js +import apiClient from './api' + +const getComments = (sharedDayId, page = 1) => + apiClient.get(`/api/shared-days/${sharedDayId}/comments?page=${page}`) + +const createComment = (sharedDayId, data) => + apiClient.post(`/api/shared-days/${sharedDayId}/comments`, data) + +const updateComment = (sharedDayId, commentId, data) => + apiClient.put(`/api/shared-days/${sharedDayId}/comments/${commentId}`, data) + +const deleteComment = (sharedDayId, commentId) => + apiClient.delete(`/api/shared-days/${sharedDayId}/comments/${commentId}`) + +export default { getComments, createComment, updateComment, deleteComment } \ No newline at end of file diff --git a/frontend/src/services/invitationService.js b/frontend/src/services/invitationService.js new file mode 100644 index 0000000..123d8d8 --- /dev/null +++ b/frontend/src/services/invitationService.js @@ -0,0 +1,29 @@ +import apiClient from "./api"; +const createInvitation = (params, channel) => apiClient.post(`/api/channels/${channel}/invitations`, params) +const getUsers = (params) => apiClient.get('/api/users', {params}) +const getChanelInvitations = (channel) => apiClient.get(`/api/channels/${channel}/invitations`) +const getMyInvitations = (params)=> apiClient.get('api/me/invitations', {params}); +const cancelInvitation = (channel,invitation) =>apiClient.put(`/api/channels/${channel}/invitations/${invitation}`, + { + status : "cancelled" + } +) +const acceptInvitation = (channel,invitation) =>apiClient.put(`/api/channels/${channel}/invitations/${invitation}`, + { + status : "accepted" + } +) +const declineInvitation = (channel,invitation) =>apiClient.put(`/api/channels/${channel}/invitations/${invitation}`, + { + status : "declined" + } +) +export default{ + createInvitation, + getChanelInvitations, + getUsers, + getMyInvitations, + cancelInvitation, + acceptInvitation, + declineInvitation +} \ No newline at end of file diff --git a/frontend/src/services/reportService.js b/frontend/src/services/reportService.js new file mode 100644 index 0000000..676e6be --- /dev/null +++ b/frontend/src/services/reportService.js @@ -0,0 +1,3 @@ +import apiClient from "./api"; +const generateReport = (params) => apiClient.get('/api/reports', {params}) +export default { generateReport } \ No newline at end of file diff --git a/frontend/src/services/sharedDayService.js b/frontend/src/services/sharedDayService.js new file mode 100644 index 0000000..46cd9e8 --- /dev/null +++ b/frontend/src/services/sharedDayService.js @@ -0,0 +1,18 @@ +import apiClient from "./api"; + + +const createSharedDay = (params) => apiClient.post('/api/shared-days', params) +const getChannelSharedDays = (channel, params) => apiClient.get(`/api/channels/${channel}/shared-days`, {params}) +const getMySharedDays = ()=> apiClient.get('api/me/shared-days') +const getSharedDay = (channel, sharedDay) => apiClient.get(`/api/channels/${channel}/shared-days/${sharedDay}`) +const removeSharedDay = (channel, sharedDay) => apiClient.delete(`/api/channels/${channel}/shared-days/${sharedDay}`) + + +export default { + +createSharedDay, +getChannelSharedDays, +getMySharedDays, +getSharedDay, +removeSharedDay +}; diff --git a/frontend/src/services/timeEntryService.js b/frontend/src/services/timeEntryService.js index 874e93f..10af3c7 100644 --- a/frontend/src/services/timeEntryService.js +++ b/frontend/src/services/timeEntryService.js @@ -2,11 +2,13 @@ import apiClient from "./api"; const getTimeEntries = (params) => apiClient.get('/api/time-entries', {params}) const createTimeEntry = (params) => apiClient.post('/api/time-entries', params) +const updateTimeEntry = (params,id) => apiClient.put(`/api/time-entries/${id}`, params) const deleteTimeEntry = (id) => apiClient.delete(`/api/time-entries/${id}`) export default { getTimeEntries, createTimeEntry, + updateTimeEntry, deleteTimeEntry }; diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index b6a46ba..4622a1d 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -25,6 +25,19 @@ export const useAuthStore = defineStore('authStore', { } catch (error) { return {success: false} } +}, +async googleLogin(googleToken) { + const { data } = await apiClient.post('/api/auth/google', { + token: googleToken // now an access token instead of ID token + }) + + this.token = data.token + this.user = data.user + this.isAuthenticated = true + localStorage.setItem('token', data.token) + + router.push({ name: 'Home' }) + return { success: true } } , diff --git a/frontend/src/stores/commentStore.js b/frontend/src/stores/commentStore.js new file mode 100644 index 0000000..50690a1 --- /dev/null +++ b/frontend/src/stores/commentStore.js @@ -0,0 +1,46 @@ +import { defineStore } from 'pinia' +import commentService from '@/services/commentService' + +export const useCommentStore = defineStore('commentStore', { + state: () => ({ + comments: [], + meta: { current_page: 1, last_page: 1 }, + loading: false, + error: null, + }), + + getters: { + hasMore: (state) => state.meta.current_page < state.meta.last_page + }, + + actions: { + async fetch(sharedDayId, page = 1) { + this.loading = true + this.error = null + try { + const response = await commentService.getComments(sharedDayId, { page }) + this.comments = page === 1 + ? response.data.data + : [...this.comments, ...response.data.data] + this.meta = response.data.meta + } catch (err) { + this.error = err.response?.data?.errors ?? err.message + } finally { + this.loading = false + } + }, + + loadMore(sharedDayId) { + if (!this.hasMore || this.loading) return + this.fetch(sharedDayId, this.meta.current_page + 1) + }, + + add(comment) { this.comments.unshift(comment) }, + remove(id) { this.comments = this.comments.filter(c => c.id !== id) }, + update(updated) { + const i = this.comments.findIndex(c => c.id === updated.id) + if (i !== -1) this.comments[i] = updated + }, + reset() { this.$reset() } + } +}) \ No newline at end of file diff --git a/frontend/src/views/About.vue b/frontend/src/views/About.vue index ed7114c..8b286e1 100644 --- a/frontend/src/views/About.vue +++ b/frontend/src/views/About.vue @@ -1,3 +1,18 @@ + + \ No newline at end of file diff --git a/frontend/src/views/AddForm.vue b/frontend/src/views/AddForm.vue index f63ef8c..d808447 100644 --- a/frontend/src/views/AddForm.vue +++ b/frontend/src/views/AddForm.vue @@ -1,9 +1,11 @@