! Published.
All checks were successful
Build docker image / build_docker_image (push) Successful in 3m50s

This commit is contained in:
2025-05-01 19:26:39 +02:00
commit 7393af96ac
290 changed files with 40752 additions and 0 deletions

39
src/.env.example Normal file
View File

@@ -0,0 +1,39 @@
APP_NAME=Backend
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_TIMEZONE=UTC
APP_URL=http://localhost:9000
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
APP_MAINTENANCE_STORE=database
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
DB_DATABASE=/app/Backend/database/sqlite/database.sqlite
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=file
CACHE_PREFIX=
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=PlEaSe_ChAnGe_Me

11
src/.gitattributes vendored Normal file
View File

@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

22
src/.gitignore vendored Normal file
View File

@@ -0,0 +1,22 @@
/.phpunit.cache
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/vendor
.env
.env.backup
.env.production
.phpunit.result.cache
.editorconfig
Homestead.json
Homestead.yaml
auth.json
npm-debug.log
yarn-error.log
/.fleet
/.idea
/.vscode
/.php-cs-fixer.cache
tests/Coverage

View File

@@ -0,0 +1,35 @@
<?php
use PhpCsFixer\Config;
use PhpCsFixer\Finder;
return (new Config())
->setRules([
'@PER-CS' => true,
'@PHP82Migration' => true,
'new_with_parentheses' => [
'anonymous_class' => false,
],
'braces_position' => [
'anonymous_classes_opening_brace' => 'next_line_unless_newline_at_signature_end',
],
'function_declaration' => [
'closure_fn_spacing' => 'one',
'closure_function_spacing' => 'one',
],
'concat_space' => [
'spacing' => 'none',
],
'single_trait_insert_per_statement' => false,
'no_blank_lines_after_class_opening' => false,
])
->setFinder(
(new Finder())
->in(__DIR__)
->exclude([
'storage/framework/views',
'bootstrap/cache',
])
)
;

4
src/.scribe/.filehashes Normal file
View File

@@ -0,0 +1,4 @@
# GENERATED. YOU SHOULDN'T MODIFY OR DELETE THIS FILE.
# Scribe uses this file to know when you change something manually in your docs.
.scribe/intro.md=02e48ceeff338f664f57072da0297805
.scribe/auth.md=5ea0b82b0e0252887e6b1c8bc7382937

156
src/.scribe/append.md Normal file
View File

@@ -0,0 +1,156 @@
# Classes
## DiscordUser
This record stores the general informations of a discord user.
### Fields
- **id** - *The unique ID of the DiscordUser record.*
- **snowflake** - *The unique snowflake of the discord user.*
- **user_name** - *The name of the discord user.*
- **global_name** - *The global name of the discord user.*
- **locale** - *The locale of the discord user.*
- **timezone** - *The timezone of the discord user.*
> Exaple:
```json
{
"id": 42,
"snowflake": "481398158916845568",
"user_name": "bigfootmcfly",
"global_name": "BigFoot McFly",
"locale": "hu_HU",
"timezone": "Europe/Budapest"
}
```
## Remainder
### Fields
- **id** - *The unique ID of the Remainder record.*
- **discord_user_id** - *The internal ID of the [DiscordUser](#discorduser).*
- **channel_id** - *The snowflake of the channel the remainder should be sent to.*
- **due_at** - *The "Due at" time ([timestamp](#timestamp)) of the remainder.*
- **message** - *The message to send to the discord user.*
- **status** - *The [status](#remainderstatus) of the remainder.*
- **error** - *The error (if any) that caused the remainder to fail.*
> Example:
```json
{
"id": 18568,
"discord_user_id": 42,
"channel_id": null,
"due_at": 1732950700,
"message": "Maintance completed.",
"status": "new",
"error": null
}
```
# Types
## Timestamp
It measures time by the number of non-leap seconds that have elapsed since 00:00:00 UTC on 1 January 1970, the Unix epoch.
See: <a href="https://en.wikipedia.org/wiki/Unix_time" target="_blank">Unix time on Wikipedia</a>
See: <a href="https://www.unixtimestamp.com/" target="_blank">Timestamp converter</a>
## Snowflake
A unique identifier within the discord namespace.
See: <a href="https://discord.com/developers/docs/reference#snowflakes" target="_blank">Discord reference #snowflakes</a>
## Locale
A valid local identifier.
<a href="https://github.com/Nerdtrix/language-list/blob/main/language-list-json.json" target="_blank">Locale list (json)</a>
## Timezone
A valid timezone.
<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones" target="_blank">Timezone list</a>
## RemainderStatus
The status of a remainder.
Available values:
- **new** - *New remainder.*
- **failed** - *Something went wrong, will not try to resend it.*
- **pending** - *The remainder is currently being processed.*
- **deleted** - *The remainder was deleted.*
- **finished** - *The remainder finished succesfully.*
- **cancelled** - *The remainder was cancelled.*
# Error responses
## Unauthorized (401)
In case no authentication is provided or the authentication fails, a **`401, Unauthorized`** response is returned.
> Exa
mple response (401, Unauthorized):
```json
{
"message": "Unauthenticated."
}
```
## Not Found (404)
If the requested record cannot be found, a **`404, Not Found`** response is returned.
> Example response (404, Not Found):
```json
{
"message": "Not Found."
}
```
## Unprocessable Content (422)
If the request cannot be fulfilled, a **`422, Unprocessable Content`** response is returned.
See the returned message for details about the failure.
This is mostly due to **`validation errors`**.
> Example response (422, Unprocessable Content):
```json
{
"errors": {
"snowflake": [
"Invalid snowflake"
]
}
}
```
## Internal Server Error (500)
An internal server error occured. Please try again later or contact the operator.
> Example response (500, Internal Server Error):
```json
{
"message": "Server Error"
}
```

7
src/.scribe/auth.md Normal file
View File

@@ -0,0 +1,7 @@
# Authenticating requests
To authenticate requests, include an **`Authorization`** header with the value **`"Bearer {YOUR_AUTH_KEY}"`**.
All authenticated endpoints are marked with a `requires authentication` badge in the documentation below.
You can manage your tokens at the **`profile`** page in the **`API Tokens`** section.

View File

@@ -0,0 +1,435 @@
## Autogenerated by Scribe. DO NOT MODIFY.
name: 'Discord User Managment'
description: |-
APIs to manage [DiscordUser](#discorduser) records.
These endpoints can be used to Query/Update/Delete [DiscordUser](#discorduser) records.
endpoints:
-
httpMethods:
- GET
uri: api/v1/discord-users
metadata:
groupName: 'Discord User Managment'
groupDescription: |-
APIs to manage [DiscordUser](#discorduser) records.
These endpoints can be used to Query/Update/Delete [DiscordUser](#discorduser) records.
subgroup: ''
subgroupDescription: ''
title: 'List DiscorUsers.'
description: 'Paginated list of [DiscordUser](#discorduser) records.'
authenticated: true
custom: []
headers:
Authorization: 'Bearer {YOUR_AUTH_KEY}'
Content-Type: application/json
Accept: application/json
urlParameters: []
cleanUrlParameters: []
queryParameters:
page_size:
name: page_size
description: 'Items per page. Defaults to 100.'
required: false
example: 25
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
page:
name: page
description: 'Page to query. Defaults to 1.'
required: false
example: 1
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanQueryParameters:
page_size: 25
page: 1
bodyParameters: []
cleanBodyParameters: []
fileParameters: []
responses:
-
status: 200
content: '{"data":[{"id":1,"snowflake":"481398158916845568","user_name":"bigfootmcfly","global_name":"BigFoot McFly","locale":"hu_HU","timezone":"Europe/Budapest"},{"id":6,"snowflake":"860046989130727450","user_name":"Teszt Elek","global_name":"holnap_is_teszt_elek","locale":"hu","timezone":"Europe/Budapest"},{"id":12,"snowflake":"112233445566778899","user_name":"Igaz Álmos","global_name":"almos#1244","locale":null,"timezone":null}],"meta":{"current_page":1,"from":1,"last_page":1,"per_page":10,"to":3,"total":3}}'
headers: []
description: success
custom: []
responseFields: []
auth:
- headers
- Authorization
- 'Bearer DWg0pwbKhoC45vSEKiY7o2fqyuawN4F1yCC6bbiYee795197'
controller: null
method: null
route: null
custom: []
-
httpMethods:
- POST
uri: api/v1/discord-users
metadata:
groupName: 'Discord User Managment'
groupDescription: |-
APIs to manage [DiscordUser](#discorduser) records.
These endpoints can be used to Query/Update/Delete [DiscordUser](#discorduser) records.
subgroup: ''
subgroupDescription: ''
title: 'Create a new DiscordUser record.'
description: 'Creates a new [DiscordUser](#discorduser) record with the provided parameters.'
authenticated: true
custom: []
headers:
Authorization: 'Bearer {YOUR_AUTH_KEY}'
Content-Type: application/json
Accept: application/json
urlParameters: []
cleanUrlParameters: []
queryParameters: []
cleanQueryParameters: []
bodyParameters:
snowflake:
name: snowflake
description: 'A valid [snowflake](#snowflake).'
required: true
example: '481398158916845568'
type: string
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
user_name:
name: user_name
description: 'The user_name registered in Discord.'
required: false
example: bigfootmcfly
type: string
enumValues: []
exampleWasSpecified: true
nullable: true
custom: []
global_name:
name: global_name
description: 'The global_name registered in Discord.'
required: false
example: 'BigFoot McFly'
type: string
enumValues: []
exampleWasSpecified: true
nullable: true
custom: []
avatar:
name: avatar
description: 'The avatar url registered in Discord.'
required: false
example: null
type: string
enumValues: []
exampleWasSpecified: false
nullable: true
custom: []
locale:
name: locale
description: 'A valid [locale](#locale).'
required: false
example: hu_HU
type: string
enumValues: []
exampleWasSpecified: true
nullable: true
custom: []
timezone:
name: timezone
description: 'A valid [time zone](#timezone).'
required: false
example: Europe/Budapest
type: string
enumValues: []
exampleWasSpecified: true
nullable: true
custom: []
cleanBodyParameters:
snowflake: '481398158916845568'
user_name: bigfootmcfly
global_name: 'BigFoot McFly'
locale: hu_HU
timezone: Europe/Budapest
fileParameters: []
responses:
-
status: 200
content: '{"data":{"id":42,"snowflake":"481398158916845568","user_name":"bigfootmcfly","global_name":"BigFoot McFly","locale":"hu_HU","timezone":"Europe\/Budapest"}}'
headers: []
description: success
custom: []
-
status: 422
content: '{"errors":{"snowflake":["The snowflake has already been taken."]}}'
headers: []
description: 'Unprocessable Content'
custom: []
responseFields: []
auth:
- headers
- Authorization
- 'Bearer DWg0pwbKhoC45vSEKiY7o2fqyuawN4F1yCC6bbiYee795197'
controller: null
method: null
route: null
custom: []
-
httpMethods:
- GET
uri: 'api/v1/discord-users/{id}'
metadata:
groupName: 'Discord User Managment'
groupDescription: |-
APIs to manage [DiscordUser](#discorduser) records.
These endpoints can be used to Query/Update/Delete [DiscordUser](#discorduser) records.
subgroup: ''
subgroupDescription: ''
title: 'Get the specified DiscordUser record.'
description: 'Returns the specified [DiscordUser](#discorduser) record.'
authenticated: true
custom: []
headers:
Authorization: 'Bearer {YOUR_AUTH_KEY}'
Content-Type: application/json
Accept: application/json
urlParameters:
id:
name: id
description: '[DiscordUser](#discorduser) ID.'
required: true
example: 42
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanUrlParameters:
id: 42
queryParameters: []
cleanQueryParameters: []
bodyParameters: []
cleanBodyParameters: []
fileParameters: []
responses:
-
status: 200
content: '{"data":{"id":42,"snowflake":"481398158916845568","user_name":"bigfootmcfly","global_name":"BigFoot McFly","locale":"hu_HU","timezone":"Europe\/Budapest"}}'
headers: []
description: success
custom: []
responseFields: []
auth:
- headers
- Authorization
- 'Bearer DWg0pwbKhoC45vSEKiY7o2fqyuawN4F1yCC6bbiYee795197'
controller: null
method: null
route: null
custom: []
-
httpMethods:
- PUT
- PATCH
uri: 'api/v1/discord-users/{id}'
metadata:
groupName: 'Discord User Managment'
groupDescription: |-
APIs to manage [DiscordUser](#discorduser) records.
These endpoints can be used to Query/Update/Delete [DiscordUser](#discorduser) records.
subgroup: ''
subgroupDescription: ''
title: 'Update the specified DiscordUser record.'
description: 'Updates the specified [DiscordUser](#discorduser) with the supplied values.'
authenticated: true
custom: []
headers:
Authorization: 'Bearer {YOUR_AUTH_KEY}'
Content-Type: application/json
Accept: application/json
urlParameters:
id:
name: id
description: '[DiscordUser](#discorduser) ID.'
required: true
example: 42
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanUrlParameters:
id: 42
queryParameters: []
cleanQueryParameters: []
bodyParameters:
snowflake:
name: snowflake
description: 'The snowflake of the [DiscordUser](#discorduser) to update.'
required: true
example: '481398158916845568'
type: string
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
user_name:
name: user_name
description: 'The user_name registered in Discord.'
required: false
example: null
type: string
enumValues: []
exampleWasSpecified: false
nullable: true
custom: []
global_name:
name: global_name
description: 'The global_name registered in Discord.'
required: false
example: null
type: string
enumValues: []
exampleWasSpecified: false
nullable: true
custom: []
avatar:
name: avatar
description: 'The avatar url registered in Discord.'
required: false
example: null
type: string
enumValues: []
exampleWasSpecified: false
nullable: true
custom: []
locale:
name: locale
description: 'A valid locale. <a href="https://github.com/Nerdtrix/language-list/blob/main/language-list-json.json" target="_blank">Locale list (json)</a>'
required: false
example: null
type: string
enumValues: []
exampleWasSpecified: false
nullable: true
custom: []
timezone:
name: timezone
description: 'A valid [time zone](#timezone).'
required: false
example: Europe/London
type: string
enumValues: []
exampleWasSpecified: true
nullable: true
custom: []
cleanBodyParameters:
snowflake: '481398158916845568'
timezone: Europe/London
fileParameters: []
responses:
-
status: 200
content: '{"data":{"id":42,"snowflake":"481398158916845568","user_name":"bigfootmcfly","global_name":"BigFoot McFly","locale":"hu_HU","timezone":"Europe\/London"}}'
headers: []
description: ''
custom: []
-
status: 422
content: '{"errors":{"snowflake":["Invalid snowflake"]}}'
headers: []
description: 'Unprocessable Content'
custom: []
responseFields: []
auth:
- headers
- Authorization
- 'Bearer DWg0pwbKhoC45vSEKiY7o2fqyuawN4F1yCC6bbiYee795197'
controller: null
method: null
route: null
custom: []
-
httpMethods:
- DELETE
uri: 'api/v1/discord-users/{id}'
metadata:
groupName: 'Discord User Managment'
groupDescription: |-
APIs to manage [DiscordUser](#discorduser) records.
These endpoints can be used to Query/Update/Delete [DiscordUser](#discorduser) records.
subgroup: ''
subgroupDescription: ''
title: 'Remove the specified DiscordUser record.'
description: 'Removes the specified [DiscordUser](#discorduser) record **with** all the [Remainder](#remainder) records belonging to it.'
authenticated: true
custom: []
headers:
Authorization: 'Bearer {YOUR_AUTH_KEY}'
Content-Type: application/json
Accept: application/json
urlParameters:
id:
name: id
description: '[DiscordUser](#discorduser) ID.'
required: true
example: 42
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanUrlParameters:
id: 42
queryParameters: []
cleanQueryParameters: []
bodyParameters:
snowflake:
name: snowflake
description: 'The snowflake of the [DiscordUser](#discorduser) to delete.'
required: true
example: '481398158916845568'
type: string
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanBodyParameters:
snowflake: '481398158916845568'
fileParameters: []
responses:
-
status: 204
content: '{}'
headers: []
description: ''
custom: []
responseFields: []
auth:
- headers
- Authorization
- 'Bearer DWg0pwbKhoC45vSEKiY7o2fqyuawN4F1yCC6bbiYee795197'
controller: null
method: null
route: null
custom: []

View File

@@ -0,0 +1,371 @@
## Autogenerated by Scribe. DO NOT MODIFY.
name: 'Remainder Managment'
description: |-
APIs to manage Remainders records.
These endpoints can be used to Query/Update/Delete [Remainder](#remainder) records.
endpoints:
-
httpMethods:
- GET
uri: 'api/v1/discord-users/{discord_user_id}/remainders'
metadata:
groupName: 'Remainder Managment'
groupDescription: |-
APIs to manage Remainders records.
These endpoints can be used to Query/Update/Delete [Remainder](#remainder) records.
subgroup: ''
subgroupDescription: ''
title: 'List of Remainder records.'
description: 'Paginated list of [Remainder](#remainder) records belonging to the specified [DiscordUser](#discorduser).'
authenticated: true
custom: []
headers:
Authorization: 'Bearer {YOUR_AUTH_KEY}'
Content-Type: application/json
Accept: application/json
urlParameters:
discord_user_id:
name: discord_user_id
description: '[DiscordUser](#discorduser) ID.'
required: true
example: 42
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanUrlParameters:
discord_user_id: 42
queryParameters:
page_size:
name: page_size
description: 'Items per page. Defaults to 100.'
required: false
example: 25
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
page:
name: page
description: 'Page to query. Defaults to 1.'
required: false
example: 1
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanQueryParameters:
page_size: 25
page: 1
bodyParameters: []
cleanBodyParameters: []
fileParameters: []
responses:
-
status: 200
content: '{"data":[{"id":38,"discord_user_id":42,"channel_id":null,"due_at":1736259300,"message":"Update todo list","status":"new","error":null},{"id":121,"discord_user_id":42,"channel_id":null,"due_at":1736259480,"message":"Water plants","status":"new","error":null}],"meta":{"current_page":1,"from":1,"last_page":1,"per_page":25,"to":2,"total":2}}'
headers: []
description: success
custom: []
responseFields: []
auth:
- headers
- Authorization
- 'Bearer DWg0pwbKhoC45vSEKiY7o2fqyuawN4F1yCC6bbiYee795197'
controller: null
method: null
route: null
custom: []
-
httpMethods:
- POST
uri: 'api/v1/discord-users/{discord_user_id}/remainders'
metadata:
groupName: 'Remainder Managment'
groupDescription: |-
APIs to manage Remainders records.
These endpoints can be used to Query/Update/Delete [Remainder](#remainder) records.
subgroup: ''
subgroupDescription: ''
title: 'Create a new Remainder record.'
description: 'Creates a new [Remainder](#remainder) record with the provided parameters.'
authenticated: true
custom: []
headers:
Authorization: 'Bearer {YOUR_AUTH_KEY}'
Content-Type: application/json
Accept: application/json
urlParameters:
discord_user_id:
name: discord_user_id
description: '[DiscordUser](#discorduser) ID.'
required: true
example: 42
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanUrlParameters:
discord_user_id: 42
queryParameters: []
cleanQueryParameters: []
bodyParameters:
due_at:
name: due_at
description: 'The "Due at" time ([timestamp](#timestamp)) of the remainder'
required: true
example: '1732550400'
type: string
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
message:
name: message
description: 'The message to send to the discord user.'
required: true
example: 'Check maintance results!'
type: string
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
channel_id:
name: channel_id
description: 'The [snowflake](#snowflake) of the channel to send the remainder to.'
required: false
example: null
type: string
enumValues: []
exampleWasSpecified: false
nullable: true
custom: []
cleanBodyParameters:
due_at: '1732550400'
message: 'Check maintance results!'
fileParameters: []
responses:
-
status: 200
content: '{"data":{"id":18568,"discord_user_id":42,"channel_id":null,"due_at":1732550400,"message":"Check maintance results!","status":"new","error":null}}'
headers: []
description: ''
custom: []
responseFields: []
auth:
- headers
- Authorization
- 'Bearer DWg0pwbKhoC45vSEKiY7o2fqyuawN4F1yCC6bbiYee795197'
controller: null
method: null
route: null
custom: []
-
httpMethods:
- PUT
uri: 'api/v1/discord-users/{discord_user_id}/remainders/{id}'
metadata:
groupName: 'Remainder Managment'
groupDescription: |-
APIs to manage Remainders records.
These endpoints can be used to Query/Update/Delete [Remainder](#remainder) records.
subgroup: ''
subgroupDescription: ''
title: 'Update the specified Remainder record.'
description: 'Updates the specified [Remainder](#remainder) record with the provided parameters.'
authenticated: true
custom: []
headers:
Authorization: 'Bearer {YOUR_AUTH_KEY}'
Content-Type: application/json
Accept: application/json
urlParameters:
discord_user_id:
name: discord_user_id
description: '[DiscordUser](#discorduser) ID.'
required: true
example: 42
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
id:
name: id
description: '[Remainder](#remainder) ID.'
required: true
example: 18568
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanUrlParameters:
discord_user_id: 42
id: 18568
queryParameters: []
cleanQueryParameters: []
bodyParameters:
due_at:
name: due_at
description: 'The "Due at" time ([timestamp](#timestamp)) of the remainder.'
required: false
example: null
type: string
enumValues: []
exampleWasSpecified: false
nullable: true
custom: []
message:
name: message
description: 'The message to send to the discord user.'
required: false
example: null
type: string
enumValues: []
exampleWasSpecified: false
nullable: false
custom: []
channel_id:
name: channel_id
description: 'The [snowflake](#snowflake) of the channel to send the remainder to.'
required: false
example: null
type: string
enumValues: []
exampleWasSpecified: false
nullable: true
custom: []
status:
name: status
description: |-
Status of the [Remainder](#remainder).
For possible values see: [RemainderStatus](#remainderstatus)
required: false
example: failed
type: string
enumValues: []
exampleWasSpecified: true
nullable: true
custom: []
error:
name: error
description: 'Error description in case of failure.'
required: false
example: 'Unknow user'
type: string
enumValues: []
exampleWasSpecified: true
nullable: true
custom: []
cleanBodyParameters:
status: failed
error: 'Unknow user'
fileParameters: []
responses:
-
status: 200
content: '{"data":{"id":18568,"discord_user_id":42,"channel_id":null,"due_at":1732550400,"message":"Check maintance results!","status":"failed","error":"Unknow user"},"changes":{"status":{"old":"new","new":"failed"},"error":{"old":null,"new":"Unknow user"}}}'
headers: []
description: ''
custom: []
responseFields: []
auth:
- headers
- Authorization
- 'Bearer DWg0pwbKhoC45vSEKiY7o2fqyuawN4F1yCC6bbiYee795197'
controller: null
method: null
route: null
custom: []
-
httpMethods:
- DELETE
uri: 'api/v1/discord-users/{discord_user_id}/remainders/{id}'
metadata:
groupName: 'Remainder Managment'
groupDescription: |-
APIs to manage Remainders records.
These endpoints can be used to Query/Update/Delete [Remainder](#remainder) records.
subgroup: ''
subgroupDescription: ''
title: 'Remove the specified Remainder record.'
description: 'Removes the specified [Remainder](#remainder) record with the provided parameters.'
authenticated: true
custom: []
headers:
Authorization: 'Bearer {YOUR_AUTH_KEY}'
Content-Type: application/json
Accept: application/json
urlParameters:
discord_user_id:
name: discord_user_id
description: '[DiscordUser](#discorduser) ID.'
required: true
example: 42
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
id:
name: id
description: '[Remainder](#remainder) ID.'
required: true
example: 18568
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanUrlParameters:
discord_user_id: 42
id: 18568
queryParameters: []
cleanQueryParameters: []
bodyParameters:
snowflake:
name: snowflake
description: 'The [snowflake](#snowflake) of the DiscordUser of the Remainder to delete.'
required: true
example: '481398158916845568'
type: string
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanBodyParameters:
snowflake: '481398158916845568'
fileParameters: []
responses:
-
status: 204
content: '{}'
headers: []
description: ''
custom: []
responseFields: []
auth:
- headers
- Authorization
- 'Bearer DWg0pwbKhoC45vSEKiY7o2fqyuawN4F1yCC6bbiYee795197'
controller: null
method: null
route: null
custom: []

View File

@@ -0,0 +1,206 @@
## Autogenerated by Scribe. DO NOT MODIFY.
name: 'Discord User By snowflake Managment'
description: |-
APIs to manage DiscordUser records.
These endpoints can be used to identify/create DiscordUser records identified by the [snowflake](#snowflake) that already exists in the discord app.
endpoints:
-
httpMethods:
- GET
uri: 'api/v1/discord-user-by-snowflake/{discord_user_snowflake}'
metadata:
groupName: 'Discord User By snowflake Managment'
groupDescription: |-
APIs to manage DiscordUser records.
These endpoints can be used to identify/create DiscordUser records identified by the [snowflake](#snowflake) that already exists in the discord app.
subgroup: ''
subgroupDescription: ''
title: 'Get the DiscordUser identified by the specified snowflake.'
description: |-
Returns the [DiscordUser](#discorduser) record for the specified [snowflake](#snowflake), given in the url __discord_user_snowflake__ parameter.
If it cannot be found, a [**404, Not Found**](#not-found-404) error is returned.
authenticated: true
custom: []
headers:
Authorization: 'Bearer {YOUR_AUTH_KEY}'
Content-Type: application/json
Accept: application/json
urlParameters:
discord_user_snowflake:
name: discord_user_snowflake
description: 'A valid [snowflake](#snowflake).'
required: true
example: '481398158916845568'
type: string
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanUrlParameters:
discord_user_snowflake: '481398158916845568'
queryParameters: []
cleanQueryParameters: []
bodyParameters: []
cleanBodyParameters: []
fileParameters: []
responses:
-
status: 200
content: '{"data":{"id":42,"snowflake":"481398158916845568","user_name":"bigfootmcfly","global_name":"BigFoot McFly","locale":"hu_HU","timezone":"Europe\/Budapest"}}'
headers: []
description: success
custom: []
-
status: 404
content: '{"message":"Not Found."}'
headers: []
description: 'not found'
custom: []
responseFields: []
auth:
- headers
- Authorization
- 'Bearer DWg0pwbKhoC45vSEKiY7o2fqyuawN4F1yCC6bbiYee795197'
controller: null
method: null
route: null
custom: []
-
httpMethods:
- PUT
uri: 'api/v1/discord-user-by-snowflake/{snowflake}'
metadata:
groupName: 'Discord User By snowflake Managment'
groupDescription: |-
APIs to manage DiscordUser records.
These endpoints can be used to identify/create DiscordUser records identified by the [snowflake](#snowflake) that already exists in the discord app.
subgroup: ''
subgroupDescription: ''
title: 'Get _OR_ Update/Create the DiscordUser identified by the specified snowflake.'
description: |-
If the record specified by the url __discord_user_snowflake__ parameter exists, it will be updated with the data provided in the body of the request.
If it does not exists, it will be created using the given data.
Returns the **updated/created** [DiscordUser](#discorduser) record.
If anything goes wrong, a [**422, Unprocessable Content**](#unprocessable-content-422) error with more details will be returned.
authenticated: true
custom: []
headers:
Authorization: 'Bearer {YOUR_AUTH_KEY}'
Content-Type: application/json
Accept: application/json
urlParameters:
snowflake:
name: snowflake
description: 'A valid [snowflake](#snowflake).'
required: true
example: '481398158916845568'
type: string
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanUrlParameters:
snowflake: '481398158916845568'
queryParameters: []
cleanQueryParameters: []
bodyParameters:
snowflake:
name: snowflake
description: 'A valid [snowflake](#snowflake).'
required: true
example: '481398158916845568'
type: string
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
user_name:
name: user_name
description: 'The user_name registered in Discord.'
required: false
example: bigfootmcfly
type: string
enumValues: []
exampleWasSpecified: true
nullable: true
custom: []
global_name:
name: global_name
description: 'The global_name registered in Discord.'
required: false
example: 'BigFoot McFly'
type: string
enumValues: []
exampleWasSpecified: true
nullable: true
custom: []
avatar:
name: avatar
description: 'The avatar url registered in Discord.'
required: false
example: null
type: string
enumValues: []
exampleWasSpecified: false
nullable: true
custom: []
locale:
name: locale
description: 'A valid [locale](#locale).'
required: false
example: en_GB
type: string
enumValues: []
exampleWasSpecified: true
nullable: true
custom: []
timezone:
name: timezone
description: 'A valid [time zone](#timezone).'
required: false
example: Europe/London
type: string
enumValues: []
exampleWasSpecified: true
nullable: true
custom: []
cleanBodyParameters:
snowflake: '481398158916845568'
user_name: bigfootmcfly
global_name: 'BigFoot McFly'
locale: en_GB
timezone: Europe/London
fileParameters: []
responses:
-
status: 200
content: '{"data":{"id":42,"snowflake":"481398158916845568","user_name":"bigfootmcfly","global_name":"BigFoot McFly","locale":"en_GB","timezone":"Europe\/London"},"changes":{"locale":{"old":"hu_HU","new":"en_GB"},"timezone":{"old":"Europe\/Budapest","new":"Europe\/London"}}}'
headers: []
description: success
custom: []
-
status: 422
content: '{"errors":{"snowflake":["The snowflake field is required."]}}'
headers: []
description: 'Unprocessable Content'
custom: []
responseFields: []
auth:
- headers
- Authorization
- 'Bearer DWg0pwbKhoC45vSEKiY7o2fqyuawN4F1yCC6bbiYee795197'
controller: null
method: null
route: null
custom: []

View File

@@ -0,0 +1,86 @@
## Autogenerated by Scribe. DO NOT MODIFY.
name: 'Remainder by DueAt Managment'
description: |-
API to get Remainder records.
This endpoint can be used to Query the actual [Remainder](#remainder) records.
endpoints:
-
httpMethods:
- GET
uri: 'api/v1/remainder-by-due-at/{due_at}'
metadata:
groupName: 'Remainder by DueAt Managment'
groupDescription: |-
API to get Remainder records.
This endpoint can be used to Query the actual [Remainder](#remainder) records.
subgroup: ''
subgroupDescription: ''
title: 'Returns all the "actual" reaminders for the given second.'
description: ''
authenticated: true
custom: []
headers:
Authorization: 'Bearer {YOUR_AUTH_KEY}'
Content-Type: application/json
Accept: application/json
urlParameters:
due_at:
name: due_at
description: 'The time ([timestamp](#timestamp)) of the requested remainders.'
required: true
example: 1735685999
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanUrlParameters:
due_at: 1735685999
queryParameters:
page_size:
name: page_size
description: 'Items per page. Defaults to 100.'
required: false
example: 25
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
page:
name: page
description: 'Page to query. Defaults to 1.'
required: false
example: 1
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanQueryParameters:
page_size: 25
page: 1
bodyParameters: []
cleanBodyParameters: []
fileParameters: []
responses:
-
status: 200
content: '{"data":[{"id":56,"discord_user_id":42,"channel_id":null,"due_at":1735685999,"message":"Update conatiner registry!","status":"new","error":null},{"id":192,"discord_user_id":47,"channel_id":null,"due_at":1735685999,"message":"Get some milk","status":"new","error":null}],"meta":{"current_page":1,"from":1,"last_page":1,"per_page":100,"to":2,"total":2}}'
headers: []
description: success
custom: []
responseFields: []
auth:
- headers
- Authorization
- 'Bearer DWg0pwbKhoC45vSEKiY7o2fqyuawN4F1yCC6bbiYee795197'
controller: null
method: null
route: null
custom: []

View File

@@ -0,0 +1,433 @@
name: 'Discord User Managment'
description: |-
APIs to manage [DiscordUser](#discorduser) records.
These endpoints can be used to Query/Update/Delete [DiscordUser](#discorduser) records.
endpoints:
-
httpMethods:
- GET
uri: api/v1/discord-users
metadata:
groupName: 'Discord User Managment'
groupDescription: |-
APIs to manage [DiscordUser](#discorduser) records.
These endpoints can be used to Query/Update/Delete [DiscordUser](#discorduser) records.
subgroup: ''
subgroupDescription: ''
title: 'List DiscorUsers.'
description: 'Paginated list of [DiscordUser](#discorduser) records.'
authenticated: true
custom: []
headers:
Authorization: 'Bearer {YOUR_AUTH_KEY}'
Content-Type: application/json
Accept: application/json
urlParameters: []
cleanUrlParameters: []
queryParameters:
page_size:
name: page_size
description: 'Items per page. Defaults to 100.'
required: false
example: 25
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
page:
name: page
description: 'Page to query. Defaults to 1.'
required: false
example: 1
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanQueryParameters:
page_size: 25
page: 1
bodyParameters: []
cleanBodyParameters: []
fileParameters: []
responses:
-
status: 200
content: '{"data":[{"id":1,"snowflake":"481398158916845568","user_name":"bigfootmcfly","global_name":"BigFoot McFly","locale":"hu_HU","timezone":"Europe/Budapest"},{"id":6,"snowflake":"860046989130727450","user_name":"Teszt Elek","global_name":"holnap_is_teszt_elek","locale":"hu","timezone":"Europe/Budapest"},{"id":12,"snowflake":"112233445566778899","user_name":"Igaz Álmos","global_name":"almos#1244","locale":null,"timezone":null}],"meta":{"current_page":1,"from":1,"last_page":1,"per_page":10,"to":3,"total":3}}'
headers: []
description: success
custom: []
responseFields: []
auth:
- headers
- Authorization
- 'Bearer DWg0pwbKhoC45vSEKiY7o2fqyuawN4F1yCC6bbiYee795197'
controller: null
method: null
route: null
custom: []
-
httpMethods:
- POST
uri: api/v1/discord-users
metadata:
groupName: 'Discord User Managment'
groupDescription: |-
APIs to manage [DiscordUser](#discorduser) records.
These endpoints can be used to Query/Update/Delete [DiscordUser](#discorduser) records.
subgroup: ''
subgroupDescription: ''
title: 'Create a new DiscordUser record.'
description: 'Creates a new [DiscordUser](#discorduser) record with the provided parameters.'
authenticated: true
custom: []
headers:
Authorization: 'Bearer {YOUR_AUTH_KEY}'
Content-Type: application/json
Accept: application/json
urlParameters: []
cleanUrlParameters: []
queryParameters: []
cleanQueryParameters: []
bodyParameters:
snowflake:
name: snowflake
description: 'A valid [snowflake](#snowflake).'
required: true
example: '481398158916845568'
type: string
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
user_name:
name: user_name
description: 'The user_name registered in Discord.'
required: false
example: bigfootmcfly
type: string
enumValues: []
exampleWasSpecified: true
nullable: true
custom: []
global_name:
name: global_name
description: 'The global_name registered in Discord.'
required: false
example: 'BigFoot McFly'
type: string
enumValues: []
exampleWasSpecified: true
nullable: true
custom: []
avatar:
name: avatar
description: 'The avatar url registered in Discord.'
required: false
example: null
type: string
enumValues: []
exampleWasSpecified: false
nullable: true
custom: []
locale:
name: locale
description: 'A valid [locale](#locale).'
required: false
example: hu_HU
type: string
enumValues: []
exampleWasSpecified: true
nullable: true
custom: []
timezone:
name: timezone
description: 'A valid [time zone](#timezone).'
required: false
example: Europe/Budapest
type: string
enumValues: []
exampleWasSpecified: true
nullable: true
custom: []
cleanBodyParameters:
snowflake: '481398158916845568'
user_name: bigfootmcfly
global_name: 'BigFoot McFly'
locale: hu_HU
timezone: Europe/Budapest
fileParameters: []
responses:
-
status: 200
content: '{"data":{"id":42,"snowflake":"481398158916845568","user_name":"bigfootmcfly","global_name":"BigFoot McFly","locale":"hu_HU","timezone":"Europe\/Budapest"}}'
headers: []
description: success
custom: []
-
status: 422
content: '{"errors":{"snowflake":["The snowflake has already been taken."]}}'
headers: []
description: 'Unprocessable Content'
custom: []
responseFields: []
auth:
- headers
- Authorization
- 'Bearer DWg0pwbKhoC45vSEKiY7o2fqyuawN4F1yCC6bbiYee795197'
controller: null
method: null
route: null
custom: []
-
httpMethods:
- GET
uri: 'api/v1/discord-users/{id}'
metadata:
groupName: 'Discord User Managment'
groupDescription: |-
APIs to manage [DiscordUser](#discorduser) records.
These endpoints can be used to Query/Update/Delete [DiscordUser](#discorduser) records.
subgroup: ''
subgroupDescription: ''
title: 'Get the specified DiscordUser record.'
description: 'Returns the specified [DiscordUser](#discorduser) record.'
authenticated: true
custom: []
headers:
Authorization: 'Bearer {YOUR_AUTH_KEY}'
Content-Type: application/json
Accept: application/json
urlParameters:
id:
name: id
description: '[DiscordUser](#discorduser) ID.'
required: true
example: 42
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanUrlParameters:
id: 42
queryParameters: []
cleanQueryParameters: []
bodyParameters: []
cleanBodyParameters: []
fileParameters: []
responses:
-
status: 200
content: '{"data":{"id":42,"snowflake":"481398158916845568","user_name":"bigfootmcfly","global_name":"BigFoot McFly","locale":"hu_HU","timezone":"Europe\/Budapest"}}'
headers: []
description: success
custom: []
responseFields: []
auth:
- headers
- Authorization
- 'Bearer DWg0pwbKhoC45vSEKiY7o2fqyuawN4F1yCC6bbiYee795197'
controller: null
method: null
route: null
custom: []
-
httpMethods:
- PUT
- PATCH
uri: 'api/v1/discord-users/{id}'
metadata:
groupName: 'Discord User Managment'
groupDescription: |-
APIs to manage [DiscordUser](#discorduser) records.
These endpoints can be used to Query/Update/Delete [DiscordUser](#discorduser) records.
subgroup: ''
subgroupDescription: ''
title: 'Update the specified DiscordUser record.'
description: 'Updates the specified [DiscordUser](#discorduser) with the supplied values.'
authenticated: true
custom: []
headers:
Authorization: 'Bearer {YOUR_AUTH_KEY}'
Content-Type: application/json
Accept: application/json
urlParameters:
id:
name: id
description: '[DiscordUser](#discorduser) ID.'
required: true
example: 42
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanUrlParameters:
id: 42
queryParameters: []
cleanQueryParameters: []
bodyParameters:
snowflake:
name: snowflake
description: 'The snowflake of the [DiscordUser](#discorduser) to update.'
required: true
example: '481398158916845568'
type: string
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
user_name:
name: user_name
description: 'The user_name registered in Discord.'
required: false
example: null
type: string
enumValues: []
exampleWasSpecified: false
nullable: true
custom: []
global_name:
name: global_name
description: 'The global_name registered in Discord.'
required: false
example: null
type: string
enumValues: []
exampleWasSpecified: false
nullable: true
custom: []
avatar:
name: avatar
description: 'The avatar url registered in Discord.'
required: false
example: null
type: string
enumValues: []
exampleWasSpecified: false
nullable: true
custom: []
locale:
name: locale
description: 'A valid locale. <a href="https://github.com/Nerdtrix/language-list/blob/main/language-list-json.json" target="_blank">Locale list (json)</a>'
required: false
example: null
type: string
enumValues: []
exampleWasSpecified: false
nullable: true
custom: []
timezone:
name: timezone
description: 'A valid [time zone](#timezone).'
required: false
example: Europe/London
type: string
enumValues: []
exampleWasSpecified: true
nullable: true
custom: []
cleanBodyParameters:
snowflake: '481398158916845568'
timezone: Europe/London
fileParameters: []
responses:
-
status: 200
content: '{"data":{"id":42,"snowflake":"481398158916845568","user_name":"bigfootmcfly","global_name":"BigFoot McFly","locale":"hu_HU","timezone":"Europe\/London"}}'
headers: []
description: ''
custom: []
-
status: 422
content: '{"errors":{"snowflake":["Invalid snowflake"]}}'
headers: []
description: 'Unprocessable Content'
custom: []
responseFields: []
auth:
- headers
- Authorization
- 'Bearer DWg0pwbKhoC45vSEKiY7o2fqyuawN4F1yCC6bbiYee795197'
controller: null
method: null
route: null
custom: []
-
httpMethods:
- DELETE
uri: 'api/v1/discord-users/{id}'
metadata:
groupName: 'Discord User Managment'
groupDescription: |-
APIs to manage [DiscordUser](#discorduser) records.
These endpoints can be used to Query/Update/Delete [DiscordUser](#discorduser) records.
subgroup: ''
subgroupDescription: ''
title: 'Remove the specified DiscordUser record.'
description: 'Removes the specified [DiscordUser](#discorduser) record **with** all the [Remainder](#remainder) records belonging to it.'
authenticated: true
custom: []
headers:
Authorization: 'Bearer {YOUR_AUTH_KEY}'
Content-Type: application/json
Accept: application/json
urlParameters:
id:
name: id
description: '[DiscordUser](#discorduser) ID.'
required: true
example: 42
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanUrlParameters:
id: 42
queryParameters: []
cleanQueryParameters: []
bodyParameters:
snowflake:
name: snowflake
description: 'The snowflake of the [DiscordUser](#discorduser) to delete.'
required: true
example: '481398158916845568'
type: string
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanBodyParameters:
snowflake: '481398158916845568'
fileParameters: []
responses:
-
status: 204
content: '{}'
headers: []
description: ''
custom: []
responseFields: []
auth:
- headers
- Authorization
- 'Bearer DWg0pwbKhoC45vSEKiY7o2fqyuawN4F1yCC6bbiYee795197'
controller: null
method: null
route: null
custom: []

View File

@@ -0,0 +1,369 @@
name: 'Remainder Managment'
description: |-
APIs to manage Remainders records.
These endpoints can be used to Query/Update/Delete [Remainder](#remainder) records.
endpoints:
-
httpMethods:
- GET
uri: 'api/v1/discord-users/{discord_user_id}/remainders'
metadata:
groupName: 'Remainder Managment'
groupDescription: |-
APIs to manage Remainders records.
These endpoints can be used to Query/Update/Delete [Remainder](#remainder) records.
subgroup: ''
subgroupDescription: ''
title: 'List of Remainder records.'
description: 'Paginated list of [Remainder](#remainder) records belonging to the specified [DiscordUser](#discorduser).'
authenticated: true
custom: []
headers:
Authorization: 'Bearer {YOUR_AUTH_KEY}'
Content-Type: application/json
Accept: application/json
urlParameters:
discord_user_id:
name: discord_user_id
description: '[DiscordUser](#discorduser) ID.'
required: true
example: 42
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanUrlParameters:
discord_user_id: 42
queryParameters:
page_size:
name: page_size
description: 'Items per page. Defaults to 100.'
required: false
example: 25
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
page:
name: page
description: 'Page to query. Defaults to 1.'
required: false
example: 1
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanQueryParameters:
page_size: 25
page: 1
bodyParameters: []
cleanBodyParameters: []
fileParameters: []
responses:
-
status: 200
content: '{"data":[{"id":38,"discord_user_id":42,"channel_id":null,"due_at":1736259300,"message":"Update todo list","status":"new","error":null},{"id":121,"discord_user_id":42,"channel_id":null,"due_at":1736259480,"message":"Water plants","status":"new","error":null}],"meta":{"current_page":1,"from":1,"last_page":1,"per_page":25,"to":2,"total":2}}'
headers: []
description: success
custom: []
responseFields: []
auth:
- headers
- Authorization
- 'Bearer DWg0pwbKhoC45vSEKiY7o2fqyuawN4F1yCC6bbiYee795197'
controller: null
method: null
route: null
custom: []
-
httpMethods:
- POST
uri: 'api/v1/discord-users/{discord_user_id}/remainders'
metadata:
groupName: 'Remainder Managment'
groupDescription: |-
APIs to manage Remainders records.
These endpoints can be used to Query/Update/Delete [Remainder](#remainder) records.
subgroup: ''
subgroupDescription: ''
title: 'Create a new Remainder record.'
description: 'Creates a new [Remainder](#remainder) record with the provided parameters.'
authenticated: true
custom: []
headers:
Authorization: 'Bearer {YOUR_AUTH_KEY}'
Content-Type: application/json
Accept: application/json
urlParameters:
discord_user_id:
name: discord_user_id
description: '[DiscordUser](#discorduser) ID.'
required: true
example: 42
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanUrlParameters:
discord_user_id: 42
queryParameters: []
cleanQueryParameters: []
bodyParameters:
due_at:
name: due_at
description: 'The "Due at" time ([timestamp](#timestamp)) of the remainder'
required: true
example: '1732550400'
type: string
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
message:
name: message
description: 'The message to send to the discord user.'
required: true
example: 'Check maintance results!'
type: string
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
channel_id:
name: channel_id
description: 'The [snowflake](#snowflake) of the channel to send the remainder to.'
required: false
example: null
type: string
enumValues: []
exampleWasSpecified: false
nullable: true
custom: []
cleanBodyParameters:
due_at: '1732550400'
message: 'Check maintance results!'
fileParameters: []
responses:
-
status: 200
content: '{"data":{"id":18568,"discord_user_id":42,"channel_id":null,"due_at":1732550400,"message":"Check maintance results!","status":"new","error":null}}'
headers: []
description: ''
custom: []
responseFields: []
auth:
- headers
- Authorization
- 'Bearer DWg0pwbKhoC45vSEKiY7o2fqyuawN4F1yCC6bbiYee795197'
controller: null
method: null
route: null
custom: []
-
httpMethods:
- PUT
uri: 'api/v1/discord-users/{discord_user_id}/remainders/{id}'
metadata:
groupName: 'Remainder Managment'
groupDescription: |-
APIs to manage Remainders records.
These endpoints can be used to Query/Update/Delete [Remainder](#remainder) records.
subgroup: ''
subgroupDescription: ''
title: 'Update the specified Remainder record.'
description: 'Updates the specified [Remainder](#remainder) record with the provided parameters.'
authenticated: true
custom: []
headers:
Authorization: 'Bearer {YOUR_AUTH_KEY}'
Content-Type: application/json
Accept: application/json
urlParameters:
discord_user_id:
name: discord_user_id
description: '[DiscordUser](#discorduser) ID.'
required: true
example: 42
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
id:
name: id
description: '[Remainder](#remainder) ID.'
required: true
example: 18568
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanUrlParameters:
discord_user_id: 42
id: 18568
queryParameters: []
cleanQueryParameters: []
bodyParameters:
due_at:
name: due_at
description: 'The "Due at" time ([timestamp](#timestamp)) of the remainder.'
required: false
example: null
type: string
enumValues: []
exampleWasSpecified: false
nullable: true
custom: []
message:
name: message
description: 'The message to send to the discord user.'
required: false
example: null
type: string
enumValues: []
exampleWasSpecified: false
nullable: false
custom: []
channel_id:
name: channel_id
description: 'The [snowflake](#snowflake) of the channel to send the remainder to.'
required: false
example: null
type: string
enumValues: []
exampleWasSpecified: false
nullable: true
custom: []
status:
name: status
description: |-
Status of the [Remainder](#remainder).
For possible values see: [RemainderStatus](#remainderstatus)
required: false
example: failed
type: string
enumValues: []
exampleWasSpecified: true
nullable: true
custom: []
error:
name: error
description: 'Error description in case of failure.'
required: false
example: 'Unknow user'
type: string
enumValues: []
exampleWasSpecified: true
nullable: true
custom: []
cleanBodyParameters:
status: failed
error: 'Unknow user'
fileParameters: []
responses:
-
status: 200
content: '{"data":{"id":18568,"discord_user_id":42,"channel_id":null,"due_at":1732550400,"message":"Check maintance results!","status":"failed","error":"Unknow user"},"changes":{"status":{"old":"new","new":"failed"},"error":{"old":null,"new":"Unknow user"}}}'
headers: []
description: ''
custom: []
responseFields: []
auth:
- headers
- Authorization
- 'Bearer DWg0pwbKhoC45vSEKiY7o2fqyuawN4F1yCC6bbiYee795197'
controller: null
method: null
route: null
custom: []
-
httpMethods:
- DELETE
uri: 'api/v1/discord-users/{discord_user_id}/remainders/{id}'
metadata:
groupName: 'Remainder Managment'
groupDescription: |-
APIs to manage Remainders records.
These endpoints can be used to Query/Update/Delete [Remainder](#remainder) records.
subgroup: ''
subgroupDescription: ''
title: 'Remove the specified Remainder record.'
description: 'Removes the specified [Remainder](#remainder) record with the provided parameters.'
authenticated: true
custom: []
headers:
Authorization: 'Bearer {YOUR_AUTH_KEY}'
Content-Type: application/json
Accept: application/json
urlParameters:
discord_user_id:
name: discord_user_id
description: '[DiscordUser](#discorduser) ID.'
required: true
example: 42
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
id:
name: id
description: '[Remainder](#remainder) ID.'
required: true
example: 18568
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanUrlParameters:
discord_user_id: 42
id: 18568
queryParameters: []
cleanQueryParameters: []
bodyParameters:
snowflake:
name: snowflake
description: 'The [snowflake](#snowflake) of the DiscordUser of the Remainder to delete.'
required: true
example: '481398158916845568'
type: string
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanBodyParameters:
snowflake: '481398158916845568'
fileParameters: []
responses:
-
status: 204
content: '{}'
headers: []
description: ''
custom: []
responseFields: []
auth:
- headers
- Authorization
- 'Bearer DWg0pwbKhoC45vSEKiY7o2fqyuawN4F1yCC6bbiYee795197'
controller: null
method: null
route: null
custom: []

View File

@@ -0,0 +1,204 @@
name: 'Discord User By snowflake Managment'
description: |-
APIs to manage DiscordUser records.
These endpoints can be used to identify/create DiscordUser records identified by the [snowflake](#snowflake) that already exists in the discord app.
endpoints:
-
httpMethods:
- GET
uri: 'api/v1/discord-user-by-snowflake/{discord_user_snowflake}'
metadata:
groupName: 'Discord User By snowflake Managment'
groupDescription: |-
APIs to manage DiscordUser records.
These endpoints can be used to identify/create DiscordUser records identified by the [snowflake](#snowflake) that already exists in the discord app.
subgroup: ''
subgroupDescription: ''
title: 'Get the DiscordUser identified by the specified snowflake.'
description: |-
Returns the [DiscordUser](#discorduser) record for the specified [snowflake](#snowflake), given in the url __discord_user_snowflake__ parameter.
If it cannot be found, a [**404, Not Found**](#not-found-404) error is returned.
authenticated: true
custom: []
headers:
Authorization: 'Bearer {YOUR_AUTH_KEY}'
Content-Type: application/json
Accept: application/json
urlParameters:
discord_user_snowflake:
name: discord_user_snowflake
description: 'A valid [snowflake](#snowflake).'
required: true
example: '481398158916845568'
type: string
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanUrlParameters:
discord_user_snowflake: '481398158916845568'
queryParameters: []
cleanQueryParameters: []
bodyParameters: []
cleanBodyParameters: []
fileParameters: []
responses:
-
status: 200
content: '{"data":{"id":42,"snowflake":"481398158916845568","user_name":"bigfootmcfly","global_name":"BigFoot McFly","locale":"hu_HU","timezone":"Europe\/Budapest"}}'
headers: []
description: success
custom: []
-
status: 404
content: '{"message":"Not Found."}'
headers: []
description: 'not found'
custom: []
responseFields: []
auth:
- headers
- Authorization
- 'Bearer DWg0pwbKhoC45vSEKiY7o2fqyuawN4F1yCC6bbiYee795197'
controller: null
method: null
route: null
custom: []
-
httpMethods:
- PUT
uri: 'api/v1/discord-user-by-snowflake/{snowflake}'
metadata:
groupName: 'Discord User By snowflake Managment'
groupDescription: |-
APIs to manage DiscordUser records.
These endpoints can be used to identify/create DiscordUser records identified by the [snowflake](#snowflake) that already exists in the discord app.
subgroup: ''
subgroupDescription: ''
title: 'Get _OR_ Update/Create the DiscordUser identified by the specified snowflake.'
description: |-
If the record specified by the url __discord_user_snowflake__ parameter exists, it will be updated with the data provided in the body of the request.
If it does not exists, it will be created using the given data.
Returns the **updated/created** [DiscordUser](#discorduser) record.
If anything goes wrong, a [**422, Unprocessable Content**](#unprocessable-content-422) error with more details will be returned.
authenticated: true
custom: []
headers:
Authorization: 'Bearer {YOUR_AUTH_KEY}'
Content-Type: application/json
Accept: application/json
urlParameters:
snowflake:
name: snowflake
description: 'A valid [snowflake](#snowflake).'
required: true
example: '481398158916845568'
type: string
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanUrlParameters:
snowflake: '481398158916845568'
queryParameters: []
cleanQueryParameters: []
bodyParameters:
snowflake:
name: snowflake
description: 'A valid [snowflake](#snowflake).'
required: true
example: '481398158916845568'
type: string
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
user_name:
name: user_name
description: 'The user_name registered in Discord.'
required: false
example: bigfootmcfly
type: string
enumValues: []
exampleWasSpecified: true
nullable: true
custom: []
global_name:
name: global_name
description: 'The global_name registered in Discord.'
required: false
example: 'BigFoot McFly'
type: string
enumValues: []
exampleWasSpecified: true
nullable: true
custom: []
avatar:
name: avatar
description: 'The avatar url registered in Discord.'
required: false
example: null
type: string
enumValues: []
exampleWasSpecified: false
nullable: true
custom: []
locale:
name: locale
description: 'A valid [locale](#locale).'
required: false
example: en_GB
type: string
enumValues: []
exampleWasSpecified: true
nullable: true
custom: []
timezone:
name: timezone
description: 'A valid [time zone](#timezone).'
required: false
example: Europe/London
type: string
enumValues: []
exampleWasSpecified: true
nullable: true
custom: []
cleanBodyParameters:
snowflake: '481398158916845568'
user_name: bigfootmcfly
global_name: 'BigFoot McFly'
locale: en_GB
timezone: Europe/London
fileParameters: []
responses:
-
status: 200
content: '{"data":{"id":42,"snowflake":"481398158916845568","user_name":"bigfootmcfly","global_name":"BigFoot McFly","locale":"en_GB","timezone":"Europe\/London"},"changes":{"locale":{"old":"hu_HU","new":"en_GB"},"timezone":{"old":"Europe\/Budapest","new":"Europe\/London"}}}'
headers: []
description: success
custom: []
-
status: 422
content: '{"errors":{"snowflake":["The snowflake field is required."]}}'
headers: []
description: 'Unprocessable Content'
custom: []
responseFields: []
auth:
- headers
- Authorization
- 'Bearer DWg0pwbKhoC45vSEKiY7o2fqyuawN4F1yCC6bbiYee795197'
controller: null
method: null
route: null
custom: []

View File

@@ -0,0 +1,84 @@
name: 'Remainder by DueAt Managment'
description: |-
API to get Remainder records.
This endpoint can be used to Query the actual [Remainder](#remainder) records.
endpoints:
-
httpMethods:
- GET
uri: 'api/v1/remainder-by-due-at/{due_at}'
metadata:
groupName: 'Remainder by DueAt Managment'
groupDescription: |-
API to get Remainder records.
This endpoint can be used to Query the actual [Remainder](#remainder) records.
subgroup: ''
subgroupDescription: ''
title: 'Returns all the "actual" reaminders for the given second.'
description: ''
authenticated: true
custom: []
headers:
Authorization: 'Bearer {YOUR_AUTH_KEY}'
Content-Type: application/json
Accept: application/json
urlParameters:
due_at:
name: due_at
description: 'The time ([timestamp](#timestamp)) of the requested remainders.'
required: true
example: 1735685999
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanUrlParameters:
due_at: 1735685999
queryParameters:
page_size:
name: page_size
description: 'Items per page. Defaults to 100.'
required: false
example: 25
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
page:
name: page
description: 'Page to query. Defaults to 1.'
required: false
example: 1
type: integer
enumValues: []
exampleWasSpecified: true
nullable: false
custom: []
cleanQueryParameters:
page_size: 25
page: 1
bodyParameters: []
cleanBodyParameters: []
fileParameters: []
responses:
-
status: 200
content: '{"data":[{"id":56,"discord_user_id":42,"channel_id":null,"due_at":1735685999,"message":"Update conatiner registry!","status":"new","error":null},{"id":192,"discord_user_id":47,"channel_id":null,"due_at":1735685999,"message":"Get some milk","status":"new","error":null}],"meta":{"current_page":1,"from":1,"last_page":1,"per_page":100,"to":2,"total":2}}'
headers: []
description: success
custom: []
responseFields: []
auth:
- headers
- Authorization
- 'Bearer DWg0pwbKhoC45vSEKiY7o2fqyuawN4F1yCC6bbiYee795197'
controller: null
method: null
route: null
custom: []

View File

@@ -0,0 +1,53 @@
# To include an endpoint that isn't a part of your Laravel app (or belongs to a vendor package),
# you can define it in a custom.*.yaml file, like this one.
# Each custom file should contain an array of endpoints. Here's an example:
# See https://scribe.knuckles.wtf/laravel/documenting/custom-endpoints#extra-sorting-groups-in-custom-endpoint-files for more options
#- httpMethods:
# - POST
# uri: api/doSomething/{param}
# metadata:
# groupName: The group the endpoint belongs to. Can be a new group or an existing group.
# groupDescription: A description for the group. You don't need to set this for every endpoint; once is enough.
# subgroup: You can add a subgroup, too.
# title: Do something
# description: 'This endpoint allows you to do something.'
# authenticated: false
# headers:
# Content-Type: application/json
# Accept: application/json
# urlParameters:
# param:
# name: param
# description: A URL param for no reason.
# required: true
# example: 2
# type: integer
# queryParameters:
# speed:
# name: speed
# description: How fast the thing should be done. Can be `slow` or `fast`.
# required: false
# example: fast
# type: string
# bodyParameters:
# something:
# name: something
# description: The things we should do.
# required: true
# example:
# - string 1
# - string 2
# type: 'string[]'
# responses:
# - status: 200
# description: 'When the thing was done smoothly.'
# content: # Your response content can be an object, an array, a string or empty.
# {
# "hey": "ho ho ho"
# }
# responseFields:
# hey:
# name: hey
# description: Who knows?
# type: string # This is optional

13
src/.scribe/intro.md Normal file
View File

@@ -0,0 +1,13 @@
# Introduction
Documentation for the Backend API.
<aside>
<strong>Base URL</strong>: <code>localhost:9000</code>
</aside>
This documentation aims to provide all the information you need to work with our API.
<aside>As you scroll, you'll see code examples for working with the API in different programming languages in the dark area to the right (or as part of the content on mobile).
You can switch the language used with the tabs at the top right (or from the nav menu at the top left on mobile).</aside>

87
src/README.md Normal file
View File

@@ -0,0 +1,87 @@
![Docker build](https://proxima.goliath.hu/proxima/backend/actions/workflows/testing.yaml/badge.svg)
![Docker build](https://proxima.goliath.hu/proxima/backend/actions/workflows/latest.yaml/badge.svg)
![Proxima Discord bot](../res/logo.svg)
## The source code of the backend.
This is just a brief list of the structure and functionality for the source tree, the default, mianly unchanged laravel filles will not be covered by this list.
The source files are well documented, fell free to read trough them.
- [app](app)<br>
The laravel application.
- [Actions](app/Actions)<br>
Custom actions used by the application.
- [Attributes](app/Attributes)<br>
Custom attributes used by the application.
- [Enums](app/Enums)<br>
Enums to ensure data integrity.
- [Filament](app/Filament)<br>
The definitions for the admi UI.
- [Filament](app/Filament)<br>
The definitions for the admi UI.
- [Helpers](app/Helpers)<br>
Some helper functions for convenience.
- [Http/Controllers](app/Http/Controllers)<br>
The controllers to handle all web/api requests
- [Http/Controllers](app/Http/Controllers)<br>
The controllers to handle all web/api requests
- [Http/Controllers/Api/v1](app/Http/Controllers/Api/v1)<br>
The controllers to handle the API requests
- [Http/Middleware](app/Http/Middleware)<br>
The custom middleware to handle all web/api requests
- [HardenHeaders.php](app/Http/Middleware/HardenHeaders.php)<br>
Hides some unnecessary informations from the bots.
- [StripPaginationInfo.php](app/Http/Middleware/StripPaginationInfo.php)<br>
Removes web links from API paginated responses.
- [Http/Requests](app/Http/Requests)<br>
Custom request classes to validate input data for all requests.
- [Http/Resources/Api/v1](app/Http/Resources/Api/v1)<br>
Custom resource classes to return only the needed data for the API calls.
- [Http/Livewire](app/Http/Livewire)<br>
One lonly custom Livewire component, that was missing from filament.
- [Http/Models](app/Http/Models)<br>
The models used by the application.
- [Http/Providers](app/Http/Providers)<br>
The service providers for the application.
- [Http/Traits](app/Http/Traits)<br>
Common code used by multiple classes/enums.
- [Http/Validators/SnowflakeValidator.php](app/Http/ValidatorsSnowflakeValidator.php)<br>
Minimalistic validator for discord snaowflake.
- [database](database)<br>
Database migrations, factories, seeders.
- [public/docs](public/docs)<br>
The documentation for the API, created by <a href="https://scribe.knuckles.wtf/laravel" target="_blank">Scribe</a><br>
The full API documentation is available <a href="proxima.goliath.hu/docs" target="_blank">here</a>.
- [routes/api.php](routes/api.php)<br>
The routes the API access.
- [routes/console.php](routes/console.php)<br>
The custom inline commands for the application.
- [tests](tests)<br>
Feature tests for the application.

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Actions;
use App\Enums\RemainderStatus;
use App\Models\DiscordUser;
use Illuminate\Http\Response;
use App\Http\Requests\Api\v1\StoreRemainderRequest;
use App\Http\Resources\Api\v1\RemainderResource;
use App\Models\Remainder;
/**
* Creates a remainder
*/
class CreateRemainderAction
{
/**
* Creates a remainder with validated data
*
* @param StoreRemainderRequest $request The validated request
* @param DiscordUser $discordUser The DiscordUser owner of the Remainder
*
* @return Response [201] Returns the created Remainder
*
*/
public static function run(StoreRemainderRequest $request, DiscordUser $discordUser): Response
{
$remainder = Remainder::create(array_merge(
$request->validated(),
[
'discord_user_id' => $discordUser->id,
'status' => RemainderStatus::NEW->value,
]
));
return response(
content: [
'data' => RemainderResource::make($remainder),
],
status: 201
);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Actions;
use App\Models\DiscordUser;
use Illuminate\Http\Response;
use Illuminate\Contracts\Routing\ResponseFactory;
use Illuminate\Support\Facades\Gate;
/**
* Deletes the DiscordUser
*
* NOTE: only performs softdelete
*/
class DeleteDiscordUserAction
{
/**
* Deletes the DiscordUser with all it's remainders
*
* @param DiscordUser $discordUser The DiscordUser to delete
*
* @return Response|ResponseFactory [204] Returns an empty response
*
*/
public static function run(DiscordUser $discordUser): Response|ResponseFactory
{
Gate::authorize('delete', $discordUser);
$discordUser->remainders()->delete();
$discordUser->delete();
return response(status: 204);
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Actions;
use App\Models\Remainder;
use Illuminate\Contracts\Routing\ResponseFactory;
use Illuminate\Http\Response;
/**
* Deletes a Remainder
*
* NOTE: only performs softdelete
*/
class DeleteRemainderAction
{
/**
* Deleted the Remainder
*
* @param Remainder $remainder The Remainder to delete
*
* @return Response|ResponseFactory [204] Returns an empty response
*
*/
public static function run(Remainder $remainder): Response|ResponseFactory
{
$remainder->delete();
return response(status: 204);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Actions;
use App\Models\DiscordUser;
use Illuminate\Http\Response;
use Illuminate\Routing\ResponseFactory;
use Illuminate\Support\Facades\Gate;
/**
* Deletes the DiscordUser
*
* NOTE: performs permanent delete
*/
class ForceDeleteDiscordUserAction
{
/**
* Deletes the DiscordUser with all it's remainders
*
* @param DiscordUser $discordUser The DiscordUser to delete
*
* @return Response|ResponseFactory [204] Returns an empty response
*
*/
public static function run(DiscordUser $discordUser): Response|ResponseFactory
{
Gate::authorize('forcedelete', $discordUser);
$discordUser->allRemainders()->forceDelete();
$discordUser->permanentDelete();
return response(status: 204);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Actions;
use App\Enums\RemainderStatus;
use App\Models\Remainder;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
/**
* Removes orphaned Remainders
*
* Removes unhandled/crashed Remainders with overdue schedules.
*
* NOTE: performs permanent delete
*/
class PruneOrphanedRemaindersAction
{
/**
* Invoke the class instance.
*
* @param bool $dryRun If true, only logging is performed, nothing will be deleted, otherwise matching Remainders will be deleted
*
* @return void
*
*/
public function __invoke(bool $dryRun = false): void
{
// how long should orphaned Remainders kept
$daysToKeep = config('proxima.maintenance.keep_orphaned_remainders_for');
// get orphaned remainders
$orphaned = Remainder::where('due_at', '<', Carbon::now()->subDays($daysToKeep))
->whereIn('status', [
RemainderStatus::NEW,
RemainderStatus::PENDING,
]);
$orphanedCount = $orphaned->count();
// create message to log
$logMessage = "{$orphanedCount} Remainder(s) are orphaned, ";
$logMessage .= $dryRun ? "(DRY RUN) Keeping orphaned remainders." : "Pruning {$orphanedCount} orphaned remainders.";
// choose log level and log the message
$logLevel = $orphanedCount > 0 ? 'warning' : 'info';
Log::$logLevel($logMessage);
// remove orphaned remainders
if (false === $dryRun) {
$orphaned->forceDelete();
}
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Actions;
use App\Models\DiscordUser;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
/**
* Removes finished Remainders
*
* Removes finished/cancelled/deleted Remainders after the keep period.
*
* NOTE: performs permanent delete
*/
class RemoveDeletedDiscordUsersAction
{
/**
* Invoke the class instance.
*
* @param bool $dryRun If true, only logging is performed, nothing will be deleted, otherwise matching DiscordUsers will be deleted
*
* @return void
*
*/
public function __invoke(bool $dryRun = false): void
{
// how long should deleted DiscordUsers kept
$daysToKeep = config('proxima.maintenance.keep_deleted_discord_users_for');
// get deleted DiscordUsers
$deleted = DiscordUser::onlyTrashed()->where('deleted_at', '<', Carbon::now()->subDays($daysToKeep));
$deletedCount = $deleted->count();
// create message to log
$logMessage = "{$deletedCount} DiscordUsers(s) are deleted, ";
$logMessage .= $dryRun ? "(DRY RUN) Keeping deleted DiscordUsers." : "Removing {$deletedCount} deleted DiscordUsers.";
// log the message
Log::info($logMessage);
// remove deleted DiscordUsers
if (false === $dryRun) {
$deleted->each(fn (DiscordUser $discordUser) => $discordUser->permanentDelete());
}
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Actions;
use App\Enums\RemainderStatus;
use App\Models\Remainder;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
/**
* Removes finished Remainders
*
* Removes finished/cancelled/deleted Remainders after the keep period.
*
* NOTE: performs permanent delete
*/
class RemoveFinishedRemaindersAction
{
/**
* Invoke the class instance.
*
* @param bool $dryRun If true, only logging is performed, nothing will be deleted, otherwise matching Remainders will be deleted
*
* @return void
*
*/
public function __invoke(bool $dryRun = false): void
{
// how long should finished remainders kept
$daysToKeep = config('proxima.maintenance.keep_finished_remainders_for');
// get finished remainders
$finished = Remainder::withTrashed()->where('due_at', '<', Carbon::now()->subDays($daysToKeep))
->whereIn('status', [
RemainderStatus::FAILED,
RemainderStatus::FINISHED,
RemainderStatus::CANCELLED,
RemainderStatus::DELETED,
]);
$finishedCount = $finished->count();
// create message to log
$logMessage = "{$finishedCount} Remainder(s) are finished, ";
$logMessage .= $dryRun ? "(DRY RUN) Keeping finished remainders." : "Removing {$finishedCount} finished remainders.";
// log the message
Log::info($logMessage);
// remove finished remainders
if (false === $dryRun) {
$finished->forceDelete();
}
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Actions;
use App\Http\Resources\Api\v1\DiscordUserResource;
use App\Models\DiscordUser;
use Illuminate\Contracts\Routing\ResponseFactory;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Gate;
/**
* Restores a trashed DiscordUser
*/
class RestoreDiscordUserAction
{
/**
* Restores the trashed DiscordUser
*
* @param DiscordUser $discordUser The DiscordUser to restore
*
* @return ResponseFactory|Response [200] The DiscordUser
*
*/
public static function run(DiscordUser $discordUser): ResponseFactory|Response
{
Gate::authorize('restore', $discordUser);
$trashedRemainders = $discordUser->remainders()->onlyTrashed();
$trashedRemainders->restore();
$discordUser->restore();
return response(
status: 200,
content: [
'data' => DiscordUserResource::make($discordUser),
],
);
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Attributes;
#[\Attribute]
class Description
{
public function __construct(
public string $description,
) {}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Enums;
use App\Attributes\Description;
use App\Traits\BackedEnumHelper;
use App\Traits\HasEnumDescription;
enum ApiPermission: string
{
use BackedEnumHelper;
use HasEnumDescription;
#[Description('Handle discord users')]
case ManageDiscordUsers = 'discord-users';
#[Description('Handle Remainders')]
case ManageDiscordUserRemainders = 'discord-user-remainders';
#[Description('Get Discord User By Snowflake')]
case ManageDiscordUserBySnowflake = 'discord-user-by-snowflake';
#[Description('Get Remainders By Due At')]
case GetRemaindersByDueAt = 'remainders-by-due-at';
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Enums;
use App\Traits\BackedEnumHelper;
enum RemainderStatus: string
{
use BackedEnumHelper;
case NEW = 'new';
case FAILED = 'failed';
case PENDING = 'pending';
case DELETED = 'deleted';
case FINISHED = 'finished';
case CANCELLED = 'cancelled';
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Filament\Actions;
use Filament\Tables\Actions\ViewAction;
use Filament\Support\Enums\IconSize;
/**
* Modal ViewAction
*/
class InfoAction
{
/**
* Creates a modal 'info' view action
*
* @return ViewAction
*
*/
public static function make(): ViewAction
{
return ViewAction::make('info')
->label('')
->icon('heroicon-o-information-circle')
->iconSize(IconSize::Small)
->iconButton()
->color('warning')
->extraAttributes(
[
'title' => 'Info',
]
);
}
}

View File

@@ -0,0 +1,149 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\DiscordUserResource\Pages;
use App\Filament\Resources\DiscordUserResource\Pages\ViewDiscordUser;
use App\Filament\Resources\DiscordUserResource\RelationManagers\RemaindersRelationManager;
use App\Filament\Resources\DiscordUserResource\Services\DiscordUserResourceForm;
use App\Filament\Resources\DiscordUserResource\Services\DiscordUserResourceInfoList;
use App\Filament\Resources\DiscordUserResource\Services\DiscordUserResourceTable;
use App\Models\DiscordUser;
use Filament\Forms\Form;
use Filament\GlobalSearch\Actions\Action;
use Filament\Infolists\Infolist;
use Filament\Resources\Resource;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Number;
class DiscordUserResource extends Resource
{
//---------------------------------------------------------------------------------------------------------------
protected static ?string $model = DiscordUser::class;
protected static ?string $navigationIcon = 'heroicon-o-users';
protected static ?string $navigationGroup = 'Backend';
protected static ?string $navigationBadgeTooltip = 'Total Discord Users count';
public static function getGlobalSearchResultActions(Model $record): array
{
return [
Action::make('View')
->url(ViewDiscordUser::getUrl(['record' => $record])),
Action::make('Remainders')
->url(DiscordUserResourceTable::getRemainderActionUrl($record)),
Action::make('edit')
->url(static::getUrl('edit', ['record' => $record]), shouldOpenInNewTab: false),
];
}
//---------------------------------------------------------------------------------------------------------------
public static function getGlobalSearchResultTitle(Model $record): string|Htmlable
{
return "{$record->global_name} ({$record->user_name})";
}
public static function getGlobalSearchResultUrl(Model $record): string
{
return DiscordUserResource::getUrl('show', ['record' => $record]);
}
//---------------------------------------------------------------------------------------------------------------
public static function getGloballySearchableAttributes(): array
{
return [
'user_name',
'global_name',
'snowflake',
];
}
//---------------------------------------------------------------------------------------------------------------
public static function getGlobalSearchResultDetails(Model $record): array
{
$result = [];
if ($record->trashed()) {
$result['deleted'] = $record->deleted_at;
}
$result['remainders'] = $record->remainders_count;
return $result;
}
//---------------------------------------------------------------------------------------------------------------
public static function getGlobalSearchEloquentQuery(): Builder
{
return parent::getGlobalSearchEloquentQuery()->withCount('remainders');
}
//---------------------------------------------------------------------------------------------------------------
public static function infolist(Infolist $infolist): Infolist
{
return DiscordUserResourceInfoList::infolist($infolist);
}
//---------------------------------------------------------------------------------------------------------------
public static function form(Form $form): Form
{
return DiscordUserResourceForm::form($form);
}
//---------------------------------------------------------------------------------------------------------------
public static function table(Table $table): Table
{
return DiscordUserResourceTable::table($table);
}
//---------------------------------------------------------------------------------------------------------------
public static function getRelations(): array
{
return [
RemaindersRelationManager::class,
];
}
//---------------------------------------------------------------------------------------------------------------
public static function getPages(): array
{
return [
'index' => Pages\ListDiscordUsers::route('/'),
'show' => Pages\ViewDiscordUser::route('/{record}'), // replaces default view
'edit' => Pages\EditDiscordUser::route('/{record}/edit'),
];
}
//---------------------------------------------------------------------------------------------------------------
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->withoutGlobalScopes([
SoftDeletingScope::class,
])
;
}
//---------------------------------------------------------------------------------------------------------------
public static function getNavigationBadge(): ?string
{
//NOTE: this gets called twice per page load, (see calls bellow), so we cache it for better performance
// GET /admin/discord-users
// POST /livewire/update (ajax)
//NOTE: this could be cached for a longer time, but then the cache had to be invalidated manually on DU count change...
return Number::format(
cache()->remember(
key: 'DiscordUserResourceBadgeCount',
ttl: 10,
callback: fn () => static::getModel()::count()
)
);
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\DiscordUserResource\Pages;
use App\Filament\Resources\DiscordUserResource;
use Filament\Resources\Pages\CreateRecord;
class CreateDiscordUser extends CreateRecord
{
protected static string $resource = DiscordUserResource::class;
}

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Filament\Resources\DiscordUserResource\Pages;
use App\Actions\DeleteDiscordUserAction;
use App\Actions\ForceDeleteDiscordUserAction;
use App\Actions\RestoreDiscordUserAction;
use App\Filament\Resources\DiscordUserResource;
use Filament\Resources\Pages\EditRecord;
use App\Models\DiscordUser;
use Filament\Actions\Action;
use Filament\Actions\ForceDeleteAction;
use Filament\Actions\RestoreAction;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
class EditDiscordUser extends EditRecord
{
protected static string $resource = DiscordUserResource::class;
//---------------------------------------------------------------------------------------------------------------
/**
* Restores trashed Discord User Action
*/
public static function restoreAction(Page $page): Action
{
$trashedRemainderCount = $page->record->trashedRemainderCount;
return RestoreAction::make()
->requiresConfirmation()
->modalDescription('Are you sure you\'d like to restore this discord user and all it\'s remainders?')
->modalIcon('heroicon-o-arrow-path')
->badge($trashedRemainderCount)
->badgeColor(
fn () => match ($trashedRemainderCount) {
0 => 'gray',
default => 'warning'
}
)
->action(function (DiscordUser $discordUser) use ($page) {
RestoreDiscordUserAction::run($discordUser);
Notification::make()
->title("Discord user \"{$discordUser->user_name}\" restored.")
->success()
->send();
cache()->forget("DiscordUserRemainderCount_{$discordUser->id}");
$page->redirect(EditDiscordUser::getUrl(['record' => $discordUser]));
});
}
//---------------------------------------------------------------------------------------------------------------
/**
* Delete Discord User Action
*
* NOTE: only performs softdelete
*/
public static function deleteAction(Page $page): Action
{
return Action::make('delete')
->requiresConfirmation()
->modalDescription('Are you sure you\'d like to delete this discord user and all it\'s remainders?')
->modalIcon('heroicon-o-trash')
->label('Delete')
->color('danger')
->badge($page->record->RemainderCount)
->badgeColor(
fn () => match ($page->record->RemainderCount) {
0 => 'gray',
default => 'warning'
}
)
->action(function (DiscordUser $discordUser) use ($page) {
DeleteDiscordUserAction::run($discordUser);
Notification::make()
->title('Discord user "'.$page->record->user_name.'" deleted.')
->success()
->send();
cache()->forget('DiscordUserTrashedRemainderCount_'.$discordUser->id);
$page->redirect(EditDiscordUser::getUrl(['record' => $discordUser]));
})
->hidden(fn (DiscordUser $discordUser) => $discordUser->trashed());
}
//---------------------------------------------------------------------------------------------------------------
/**
* Delete Discord User Action
*
* NOTE: performs permanent delete
*/
public static function forceDeleteAction(Page $page): Action
{
return ForceDeleteAction::make()
->requiresConfirmation()
->modalDescription('Are you sure you\'d like to PERMANENTLY delete this discord user and all it\'s remainders?')
->modalIcon('heroicon-o-trash')
->color('danger')
->badge($page->record->allRemainderCount)
->badgeColor(
fn () => match ($page->record->remainders_count) {
0 => 'gray',
default => 'warning'
}
)
->action(function (DiscordUser $discordUser) use ($page) {
ForceDeleteDiscordUserAction::run($discordUser);
Notification::make()
->title("Discord user \"{$discordUser->global_name}\" deleted.")
->success()
->send();
$page->redirect(ListDiscordUsers::getUrl());
});
}
//---------------------------------------------------------------------------------------------------------------
protected function getHeaderActions(): array
{
return [
self::deleteAction($this),
self::forceDeleteAction($this),
self::restoreAction($this),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\DiscordUserResource\Pages;
use App\Filament\Resources\DiscordUserResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListDiscordUsers extends ListRecords
{
protected static string $resource = DiscordUserResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\DiscordUserResource\Pages;
use App\Filament\Resources\DiscordUserResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
class ViewDiscordUser extends ViewRecord
{
protected static string $resource = DiscordUserResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make(),
];
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Filament\Resources\DiscordUserResource\RelationManagers;
use App\Filament\Resources\RemainderResource;
use App\Filament\Resources\RemainderResource\Sections\RemainderResourceInfoList;
use Filament\Forms\Form;
use Filament\Infolists\Infolist;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\HtmlString;
class RemaindersRelationManager extends RelationManager
{
protected static ?string $icon = 'heroicon-o-calendar';
protected static ?string $iconColor = 'red';
protected static string $relationship = 'remainders';
public function form(Form $form): Form
{
return RemainderResource::form($form);
}
public function table(Table $table): Table
{
// add custom icon to the related section to match the design of the main section
$heading = new HtmlString(
html: view(
view: 'filament.admin.relation-manager-section-icon',
data: [
'icon' => self::$icon,
'content' => $table->getheading(),
]
)
);
return RemainderResource::table($table)
->description('The remainders of the discord user.')
->heading($heading)
;
}
public function applyFiltersToTableQuery(Builder $query): Builder
{
// return the record even if it is trashed
if ($this->ownerRecord->trashed()) {
return $query->withTrashed();
}
return $query;
}
public function infolist(Infolist $infolist): Infolist
{
// return our own infolist instead of the default one
return RemainderResourceInfoList::infolist($infolist);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Filament\Resources\DiscordUserResource\Services;
use App\Http\Requests\Api\v1\StoreDiscordUserRequest;
use App\Http\Requests\Api\v1\UpdateDiscordUserRequest;
use App\Models\DiscordUser;
use Filament\Forms\Form;
use Filament\Forms;
use Tapp\FilamentTimezoneField\Forms\Components\TimezoneSelect;
final class DiscordUserResourceForm
{
public static function form(Form $form): Form
{
$rules = match ($form->getOperation()) {
'edit' => UpdateDiscordUserRequest::asFilamentRules(),
'create' => StoreDiscordUserRequest::asFilamentRules(),
default => [],
};
return $form
->schema([
Forms\Components\Placeholder::make('trashed')
->columnSpan(2)
->view('filament.admin.trashed')
->visible(fn (DiscordUser $discordUser) => $discordUser->trashed()),
Forms\Components\TextInput::make('snowflake')
->rules($rules['snowflake'] ?? [])
->visibleOn('create')
->disabledOn('edit'),
Forms\Components\Placeholder::make('snowflake')
->content(fn (DiscordUser $discordUser): string => $discordUser->snowflake)
->hiddenOn(['create']),
Forms\Components\TextInput::make('user_name')
->rules($rules['user_name'] ?? []),
Forms\Components\TextInput::make('global_name')
->rules($rules['global_name'] ?? []),
Forms\Components\TextInput::make('avatar')
->rules($rules['avatar'] ?? []),
Forms\Components\Select::make('locale')
->rules($rules['locale'] ?? [])
->options(array_combine(LOCALES, LOCALES)),
TimezoneSelect::make('timezone')
->searchable(),
]);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Filament\Resources\DiscordUserResource\Services;
use App\Models\DiscordUser;
use Filament\Infolists\Infolist;
use Filament\Infolists\Components\Section;
use Filament\Infolists\Components\TextEntry;
final class DiscordUserResourceInfoList
{
public static function infolist(Infolist $infolist): Infolist
{
return $infolist
->schema([
Section::make('Trashed')
->schema([
TextEntry::make('trashed')
->columnSpan(2)
->view('filament.admin.trashed')
->extraAttributes(['margin-bottom' => '1em;']),
])
->visible(fn (DiscordUser $discordUser) => $discordUser->trashed()),
Section::make('Discrod User')
->icon('heroicon-o-users')
->iconColor('info')
->description('The common data of the discord user.')
->columns(2)
->compact()
->schema([
TextEntry::make('user_name')->label('User name'),
TextEntry::make('global_name')->label('Global name'),
TextEntry::make('snowflake')->label('Snowflake'),
TextEntry::make('remainders.count')
->label('Remainders')
->state(fn (DiscordUser $discordUser): int => $discordUser->remainders()->count()),
TextEntry::make('locale')->label('Locale'),
TextEntry::make('timezone')->label('Timezone'),
]),
]);
}
}

View File

@@ -0,0 +1,186 @@
<?php
namespace App\Filament\Resources\DiscordUserResource\Services;
use App\Filament\Actions\InfoAction;
use App\Models\DiscordUser;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\ForceDeleteBulkAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
final class DiscordUserResourceTable
{
//---------------------------------------------------------------------------------------------------------------
/**
* Returns the RemainderResource::ListRemainders view filtered by the DiscordUser
*
* @param DiscordUser $discordUser The DiscordUser to view
*
* @return string The route for the view
*
* @callback_function
*
*/
public static function getRemainderActionUrl(DiscordUser $discordUser): string
{
return route('filament.admin.resources.remainders.index', [
'tableFilters' => [
'discord_user_id' =>
['value' => $discordUser->id],
],
]);
}
//---------------------------------------------------------------------------------------------------------------
/**
* Creates an Action to navigate to RemainderResource::ListRemainders view filtered by the DiscordUser
*
* @return Action The action to navigate to the view
*
*/
public static function createRemainderAction(): Action
{
return Action::make('Remainders')
->label('')
->extraAttributes(['title' => 'Remainders'])
->icon('heroicon-o-calendar')
->url(self::getRemainderActionUrl(...));
}
//---------------------------------------------------------------------------------------------------------------
/**
* Returns the RemainderResource::ViewRemainder view
*
* @param DiscordUser $discordUser The DiscordUser to view
*
* @return string The route for the view
*
* @callback_function
*
*/
public static function getViewActionUrl(DiscordUser $discordUser): string
{
return route('filament.admin.resources.discord-users.show', [
'record' => $discordUser,
]);
}
//---------------------------------------------------------------------------------------------------------------
/**
* Creates an Action to navigate to RemainderResource::ViewRemainder view
*
* @return Action The action to navigate to the view
*
*/
public static function createViewAction(): Action
{
return Action::make('View')
->label('')
->extraAttributes(['title' => 'View'])
->icon('heroicon-o-eye')
->url(self::getViewActionUrl(...));
}
//---------------------------------------------------------------------------------------------------------------
/**
* Returns the classes of a colummn based of the Remainders state
*
* @param DiscordUser $record The Remainder to get the class for (Injected by filament)
*
* @return string The class(es) based on the state of the Remainder
*
* @callback_function
*
*/
public static function getRecordClass(DiscordUser $record): string
{
if ($record->trashed()) {
return 'border-l-4 !border-l-danger-500 line-through !decoration-danger-700 !text-amber-700';
}
return 'border-l-4 !border-l-success-500 !text-yellow-400';
}
//---------------------------------------------------------------------------------------------------------------
/**
* Deletes multiple Discord Users Action
*
* NOTE: performs permanent delete
*/
public static function forceDeleteBulkAction(): ForceDeleteBulkAction
{
$action = ForceDeleteBulkAction::make()
->requiresConfirmation()
->modalDescription('Are you sure you\'d like to PERMANENTLY delete this discord user(s) and all it\'s remainders?')
->modalIcon('heroicon-o-trash')
->color('danger');
$action->action(function () use ($action): void {
$action->process(static function (Collection $records): void {
$records->each(fn (DiscordUser $record) => $record->permanentDelete());
});
$action->success();
});
return $action;
}
//---------------------------------------------------------------------------------------------------------------
public static function table(Table $table): Table
{
return $table
->recordClasses(self::getRecordClass(...))
->columns([
TextColumn::make('snowflake')
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('user_name')
->searchable(),
TextColumn::make('global_name')
->searchable(),
TextColumn::make('remainders_count')
->label('Remainders')
->badge()
->counts('remainders')
->sortable(),
TextColumn::make('locale')
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('timezone')
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('deleted_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\TrashedFilter::make(),
])
->actions([
InfoAction::make(),
self::createViewAction(),
self::createRemainderAction(),
Tables\Actions\EditAction::make()
->label('')
->extraAttributes(['title' => 'Edit']),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
self::forceDeleteBulkAction(),
Tables\Actions\RestoreBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,159 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\RemainderResource\Pages;
use App\Filament\Resources\RemainderResource\Sections\RemainderResourceForm;
use App\Filament\Resources\RemainderResource\Sections\RemainderResourceInfoList;
use App\Filament\Resources\RemainderResource\Sections\RemainderResourceTable;
use App\Models\Remainder;
use Filament\Forms\Form;
use Filament\GlobalSearch\Actions\Action;
use Filament\Infolists\Infolist;
use Filament\Resources\Resource;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Support\Number;
class RemainderResource extends Resource
{
//---------------------------------------------------------------------------------------------------------------
protected static ?string $model = Remainder::class;
protected static ?string $navigationIcon = 'heroicon-o-calendar';
protected static ?string $navigationGroup = 'Backend';
protected static ?string $navigationBadgeTooltip = 'Total Remainders count';
public static function getGlobalSearchResultActions(Model $record): array
{
// identify record
$parameters = [
'tableActionRecord' => $record->id,
];
// if trashed, set filter on table to include deleted items
if ($record->trashed()) {
$parameters['tableFilters'] = [
'trashed' => [
'value' => 1,
],
];
}
// return the actions
return [
Action::make('info')
->url(route('filament.admin.resources.remainders.index', $parameters + [
'tableAction' => 'info',
])),
Action::make('edit')
->url(route('filament.admin.resources.remainders.index', $parameters + [
'tableAction' => 'edit',
])),
];
}
//---------------------------------------------------------------------------------------------------------------
public static function getGlobalSearchResultTitle(Model $record): string|Htmlable
{
return $record->message;
}
//---------------------------------------------------------------------------------------------------------------
public static function getGloballySearchableAttributes(): array
{
return [
'message',
'channel_id',
];
}
//---------------------------------------------------------------------------------------------------------------
public static function getGlobalSearchResultDetails(Model $record): array
{
return [
'user' => $record->discordUser->global_name,
'due at' => $record->due_at,
'status' => $record->status,
];
}
//---------------------------------------------------------------------------------------------------------------
public static function infolist(Infolist $infolist): Infolist
{
return RemainderResourceInfoList::infolist($infolist);
}
//---------------------------------------------------------------------------------------------------------------
public static function form(Form $form): Form
{
return RemainderResourceForm::form($form);
}
//---------------------------------------------------------------------------------------------------------------
public static function table(Table $table): Table
{
return RemainderResourceTable::table($table);
}
//---------------------------------------------------------------------------------------------------------------
public static function getRelations(): array
{
return [
//
];
}
//---------------------------------------------------------------------------------------------------------------
public static function getPages(): array
{
return [
'index' => Pages\ListRemainders::route('/'),
//'create' => Pages\CreateRemainder::route('/create'),
//'view' => Pages\ViewRemainder::route('/{record}'),
//'edit' => Pages\EditRemainder::route('/{record}/edit'),
];
}
//---------------------------------------------------------------------------------------------------------------
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->with('discordUser')
->withoutGlobalScopes([
SoftDeletingScope::class,
])
;
}
//---------------------------------------------------------------------------------------------------------------
public static function getNavigationBadge(): ?string
{
//NOTE: this gets called twice, (see calls bellow), we cache it for better performance
// GET /admin/discord-users
// POST /livewire/update (ajax)
return Number::format(
cache()->remember(
key: 'RemainderResourceBadgeCount',
ttl: 10,
callback: fn () => static::getModel()::count()
)
);
}
//---------------------------------------------------------------------------------------------------------------
public static function getNavigationBadgeColor(): ?string
{
return 'primary';
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Filament\Resources\RemainderResource\Pages;
use App\Filament\Resources\RemainderResource;
use Filament\Resources\Pages\CreateRecord;
class CreateRemainder extends CreateRecord
{
protected static string $resource = RemainderResource::class;
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Filament\Resources\RemainderResource\Pages;
use App\Filament\Resources\RemainderResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditRemainder extends EditRecord
{
protected static string $resource = RemainderResource::class;
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\DeleteAction::make(),
Actions\ForceDeleteAction::make(),
Actions\RestoreAction::make(),
];
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Filament\Resources\RemainderResource\Pages;
use App\Filament\Resources\RemainderResource;
use Filament\Actions;
use Filament\Resources\Components\Tab;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Concerns\HasFilters;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Number;
class ListRemainders extends ListRecords
{
use HasFilters;
protected static string $resource = RemainderResource::class;
//---------------------------------------------------------------------------------------------------------------
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
//---------------------------------------------------------------------------------------------------------------
public function getTabs(): array
{
$intervals = [
'all' => [
now()->subMillenniaNoOverflow()->startOfMillennium(),
now()->addMillenniaNoOverflow()->endOfMillennium(),
],
'today' => [
now()->startOfDay(),
now()->endOfDay(),
],
'this_week' => [ //NOTE: does not includes previous dates
now()->endOfDay(),
now()->endOfWeek(),
],
'this_month' => [ //NOTE: does not includes previous dates
now()->endOfWeek(),
now()->endOfMonth(),
],
'this_year' => [ //NOTE: does not includes previous dates
now()->endOfMonth(),
now()->endOfYear(),
],
'later' => [ //NOTE: does not includes previous dates
now()->endOfYear(),
now()->addMillenniaNoOverflow()->endOfMillennium(),
],
'overdue' => [
now()->subMillenniaNoOverflow()->startOfMillennium(),
now()->startOfHour(), //NOTE: no need for higher precision for now
],
];
$tabs = array_map($this->intervalTab(...), $intervals);
$tabs['overdue']->badgeColor('danger');
return $tabs;
}
//---------------------------------------------------------------------------------------------------------------
/**
* Creates a filter tab for the table
*
* @param array $interval The interval to filter Example: [min:max]
*
* @return Tab The filter Tab
*
*/
protected function intervalTab(array $interval): Tab
{
return Tab::make()
->modifyQueryUsing(
fn (Builder $query): Builder =>
$query->whereBetween('due_at', $interval)
)
->badge(function () use ($interval) {
$query = $this
->applyFiltersToTableQuery(static::getResource()::getEloquentQuery())
->whereBetween('due_at', $interval)
;
//NOTE: cache is mainly used to get rid of duplicate queries by filament
return cache()->remember(
key: 'badge_'.crc32($query->toRawSql()),
ttl: 5,
callback: fn () => Number::format($query->count())
);
});
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\RemainderResource\Pages;
use App\Filament\Resources\RemainderResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
class ViewRemainder extends ViewRecord
{
protected static string $resource = RemainderResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make(),
];
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Filament\Resources\RemainderResource\Sections;
use App\Enums\RemainderStatus;
use App\Filament\Resources\RemainderResource\Pages\ListRemainders;
use App\Http\Requests\Api\v1\StoreRemainderRequest;
use App\Models\Remainder;
use Carbon\Carbon;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Tables\Contracts\HasTable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
final class RemainderResourceForm
{
//---------------------------------------------------------------------------------------------------------------
/**
* Returns the DiscordUser ID from the current table filter
*
* @param HasTable $livewire The current livewire componennt (automatically injected by filament)
*
* @return int|null The ID of the DiscordUser if the filtering is used, null otherwise
*
* @callback_function
*
*/
public static function getFilteredDiscordUserId(HasTable $livewire): ?int
{
return $livewire->getTableFilterState('discord_user_id')['value'];
}
//---------------------------------------------------------------------------------------------------------------
/**
* Checks if the table is filtered by DiscordUser
*
* @param HasTable $livewire The current livewire componennt (automatically injected by filament)
*
* @return bool True if a filter is present, false otherwise
*
* @callback_function
*
*/
public static function isNotFilteredByDiscordUser(HasTable $livewire): bool
{
return null === self::getFilteredDiscordUserId($livewire) ;
}
//---------------------------------------------------------------------------------------------------------------
public static function form(Form $form): Form
{
$rules = StoreRemainderRequest::asFilamentRules();
return $form
->schema([
Select::make('discord_user_id')
->prefixIcon('heroicon-o-users')
->relationship(
name: 'discordUser',
titleAttribute: 'user_name',
//NOTE: hidden()/disabled() removes the field from the data sent to the server, so we filter it instead
modifyQueryUsing: fn (Builder $query, ListRemainders $livewire) =>
($discordUserId = self::getFilteredDiscordUserId($livewire)) !== null
? $query->where('id', $discordUserId)
: $query
)
->searchable(self::isNotFilteredByDiscordUser(...))
->preload(self::isNotFilteredByDiscordUser(...))
->default(self::getFilteredDiscordUserId(...))
->native(false)
->visibleOn('create')
->selectablePlaceholder(false)
->required()
->columnSpan(2),
Placeholder::make('discordUser.user_name')
->content(fn (Remainder $remainder): string => $remainder->discordUser->user_name)
->hiddenOn(['create']),
Placeholder::make('discordUser.global_name')
->content(fn (Remainder $remainder): string => $remainder->discordUser->global_name)
->hiddenOn(['create']),
DateTimePicker::make('due_at')
->prefixIcon('heroicon-o-calendar')
->required()
->native(false)
->timezone(Auth::user()->timezone)
->rules($rules['due_at'])
->default(Carbon::now()->addMinutes(10)),
TextInput::make('channel_id')
->prefixIcon('heroicon-o-hashtag')
->placeholder('snowflake or leave empty')
->rules($rules['channel_id']),
TextInput::make('message')
->required()
->prefixIcon('heroicon-o-chat-bubble-bottom-center-text')
->placeholder('the message tor the remainder')
->rules($rules['message'])
->columnSpan(2),
Select::make('status')
->visibleOn('edit')
->options(RemainderStatus::toSelectOptions())
->selectablePlaceholder(false)
->columnSpan(2)
->default(RemainderStatus::NEW),
]);
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Filament\Resources\RemainderResource\Sections;
use App\Models\Remainder;
use Filament\Infolists\Infolist;
use Filament\Infolists\Components\Section;
use Filament\Infolists\Components\TextEntry;
final class RemainderResourceInfoList
{
//---------------------------------------------------------------------------------------------------------------
/**
* Returns the color of a field based of the Remainders state
*
* @param Remainder $record The Remainder to get the class for (Injected by filament)
*
* @return string The color based on the state of the Remainder
*
*/
public static function getStatusColor(Remainder $record): string
{
return match ($record->isFailed()) {
true => 'danger',
default => 'info',
};
}
//---------------------------------------------------------------------------------------------------------------
/**
* Creates an infoList view for RemainderResource
*
* @param Infolist $infolist The current InfoList (Injected by filament)
*
* @return Infolist The created infoList
*
*/
public static function infolist(Infolist $infolist): Infolist
{
return $infolist
->schema([
Section::make('Discrod User')
->icon('heroicon-o-users')
->iconColor('info')
->columns(2)
->schema([
TextEntry::make('discordUser.user_name')->label('User name'),
TextEntry::make('discordUser.global_name')->label('Global name'),
]),
Section::make('Remainder')
->icon('heroicon-o-calendar')
->iconColor('info')
->columns(2)
->schema([
TextEntry::make('due_at')->dateTime(),
TextEntry::make('channel_id')->placeholder('No chanel set.'),
TextEntry::make('message')->columnSpan(2),
TextEntry::make('status')->color(self::getStatusColor(...)),
TextEntry::make('error')
->placeholder('no error')
->color(self::getStatusColor(...)),
]),
]);
}
}

View File

@@ -0,0 +1,395 @@
<?php
namespace App\Filament\Resources\RemainderResource\Sections;
use App\Enums\RemainderStatus;
use App\Filament\Actions\InfoAction;
use App\Filament\Resources\DiscordUserResource\RelationManagers\RemaindersRelationManager;
use App\Filament\Resources\RemainderResource\Pages\ListRemainders;
use App\Models\Remainder;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Enums;
use Filament\Tables\Actions;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Filament\Support\Enums\IconSize;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Actions\ForceDeleteBulkAction;
use Filament\Tables\Actions\RestoreAction;
use Filament\Tables\Actions\RestoreBulkAction;
use Filament\Tables\Columns\ColumnGroup;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Filters\TrashedFilter;
use Illuminate\Support\Facades\Auth;
use Livewire\Livewire;
final class RemainderResourceTable
{
//---------------------------------------------------------------------------------------------------------------
/**
* Checks if the table is filtered by the given field
*
* @param string $field The name of the field to check for
*
* @return bool True if the table is filtered by the given field, false otherwise
*
*/
protected static function isTableFilteredBy(string $field): bool
{
$liveWire = Livewire::current();
// hide details on relation manager
if ($liveWire::class === RemaindersRelationManager::class) {
return true;
}
// hide details if the table is filtered to one user only
if ($liveWire->tableFilters[$field]['value'] ?? null !== null) {
return true;
}
// don't hide
return false;
}
//---------------------------------------------------------------------------------------------------------------
/**
* Checks if the current table is included in another view as a relation
*
* @return bool
*
* @callback_function
*
*/
protected static function isRelationManagerView(): bool
{
return is_subclass_of(
Livewire::current(),
RelationManager::class
);
}
//---------------------------------------------------------------------------------------------------------------
/**
* Checks if the DiscordUser of the Remainder is trashed
*
* @param Remainder $remainder The Remainder to check
*
* @return bool True if the DiscordUser (owner) is trashed, false otherwise
*
* @callback_function
*
*/
protected static function isDiscordUserTrashed(Remainder $remainder): bool
{
//NOTE: this needs to be cached becouse this gets called for each row twice, so caching is only needed just for a short time
return cache()->remember(
key: 'DiscordUserIsTrashed-'.$remainder->discord_user_id,
ttl: 3,
callback: fn () => $remainder->discordUser->trashed()
);
//NOTE: vendor/livewire/livewire/src/Features/SupportModels/ModelSynth.php:65 calls this query twice,
// which cannot be cached sadly...
// SQL: select * from "discord_users" where "discord_users"."id" = 1 limit 1
// but 3 queries are better then 22, so...
}
//---------------------------------------------------------------------------------------------------------------
/**
* Checks if the table is filtered by the DiscordUser
*
* @return bool true if a filter is present, falser otherwise
*
* @callback_function
*
* @callback_function
*
*/
public static function isTableFilteredByDiscordUser(): bool
{
return static::isTableFilteredBy('discord_user_id');
}
//---------------------------------------------------------------------------------------------------------------
/**
* Returns the color of a colummn based of the state of the Remainder property
*
* @param Remainder $record The Remainder to check if it is trashed or not (Injected by filament)
*
* @return string The color based on the trashed state
*
* @callback_function
*
*/
public static function getColumnColor(Remainder $record): string
{
return match ($record->trashed()) {
true => 'danger-500',
default => 'gray',
};
}
//---------------------------------------------------------------------------------------------------------------
/**
* Returns the classes of a colummn based of the Remainders state
*
* @param Remainder $record The Remainder to check if it is trashed or not (Injected by filament)
*
* @return string The class(es) based on the state of the Remainder
*
* @callback_function
*
*/
public static function getRecordClass(Remainder $record): string
{
if ($record->trashed()) {
return 'border-l-4 !border-l-danger-500 line-through !decoration-danger-700 !text-amber-700';
}
if ($record->isOverDue()) {
return 'border-l-4 !border-l-warning-500 !decoration-warning-700 !text-amber-700';
}
return 'border-l-4 !border-l-success-500 !text-yellow-400';
}
//---------------------------------------------------------------------------------------------------------------
/**
* Returns the status icon based of the Remainder
*
* @param Remainder $record The Remainder to check if it is trashed or not (Injected by filament)
*
* @return string The icon of matching the Remainders status
*
* @callback_function
*
*/
public static function getStatusIcon(Remainder $record): string
{
return match ($record->status) {
RemainderStatus::NEW => 'heroicon-o-clock',
RemainderStatus::FAILED => 'heroicon-o-x-circle',
RemainderStatus::FINISHED => 'heroicon-o-check-circle',
default => 'heroicon-o-exclamation-circle',
};
}
//---------------------------------------------------------------------------------------------------------------
/**
* Returns the status icon color based of the Remainder
*
* @param Remainder $record The Remainder to check if it is trashed or not (Injected by filament)
*
* @return string The color of the icon of the Remainders status
*
* @callback_function
*
*/
public static function getStatusIconColor(Remainder $record): string
{
return match ($record->status) {
RemainderStatus::NEW => 'info',
RemainderStatus::PENDING => 'warning',
RemainderStatus::FAILED => 'danger',
RemainderStatus::FINISHED => 'success',
default => 'gray',
};
}
//---------------------------------------------------------------------------------------------------------------
/**
* Returns the status icon color based of the Remainder
*
* @param Remainder $record The Remainder to check if it is trashed or not (Injected by filament)
*
* @return string The color of the icon of the Remainders status
*
* @callback_function
*
*/
public static function getDueAtIconColor(Remainder $record): string
{
return $record->isOverDue() ? 'danger' : 'gray';
}
//---------------------------------------------------------------------------------------------------------------
public static function table(Table $table): Table
{
return $table
->recordClasses(self::getRecordClass(...))
->columns([
ColumnGroup::make('Discord user')
->columns([
TextColumn::make('id')
->color(self::getColumnColor(...))
->toggleable(isToggledHiddenByDefault: true)
->numeric()
->sortable(),
TextColumn::make('discordUser.user_name')
->color(self::getColumnColor(...))
->hidden(self::isTableFilteredByDiscordUser(...))
->toggleable(isToggledHiddenByDefault: true)
->sortable()
->searchable(isIndividual: true)
->label('Name'),
TextColumn::make('discordUser.global_name')
->color(self::getColumnColor(...))
->hidden(self::isTableFilteredByDiscordUser(...))
->sortable()
->searchable(isIndividual: true)
->label('Global Name'),
])->alignCenter(),
ColumnGroup::make('Remainder')
->columns([
IconColumn::make('status')
->sortable()
->alignCenter()
->icon(self::getStatusIcon(...))
->color(self::getStatusIconColor(...))
->extraAttributes(fn ($state) => ['title' => $state->value]),
TextColumn::make('due_at')
->color(self::getColumnColor(...))
->dateTime()
->timezone(Auth::user()->timezone)
->sortable()
->icon('heroicon-m-clock')
->iconColor(self::getDueAtIconColor(...)),
TextColumn::make('message')
->color(self::getColumnColor(...))
->searchable()
->limit(50),
TextColumn::make('channel_id')
->searchable()
->color(self::getColumnColor(...))
->toggleable(isToggledHiddenByDefault: true)
->placeholder('No chanel set.'),
])->alignCenter(),
ColumnGroup::make('Dates')
->columns([
TextColumn::make('created_at')
->color(self::getColumnColor(...))
->toggleable(isToggledHiddenByDefault: true)
->dateTime()
->sortable(),
TextColumn::make('updated_at')
->color(self::getColumnColor(...))
->toggleable(isToggledHiddenByDefault: true)
->dateTime()
->sortable(),
TextColumn::make('deleted_at')
->color(self::getColumnColor(...))
->toggleable(isToggledHiddenByDefault: true)
->dateTime()
->sortable(),
])->alignCenter(),
])
->defaultSort('due_at')
->filters([
SelectFilter::make('discord_user_id')
->relationship(
name: 'discordUser',
titleAttribute: 'user_name',
modifyQueryUsing: fn (Builder $query, $livewire) =>
($discordUserId = $livewire->tableFilters['discord_user_id']['value']) !== null
? $query->where('id', $discordUserId)
: $query
)
->searchable()
->preload() //NOTE: this is very resource intensive for large data sets
->label('Discord User')
->hidden(static::isRelationManagerView(...))
->resetState(fn () => redirect(ListRemainders::getUrl())),
SelectFilter::make('status')
->multiple()
->hidden(static::isRelationManagerView(...))
->options(RemainderStatus::toSelectOptions()),
TernaryFilter::make('channel_id')
->hidden(self::isTableFilteredByDiscordUser(...))
->label('Channel')
->placeholder('All')
->trueLabel('Provided')
->falseLabel('Not provided')
->queries(
true: fn (Builder $query) => $query->whereNotNull('channel_id'),
false: fn (Builder $query) => $query->whereNull('channel_id'),
blank: fn (Builder $query) => $query,
),
TrashedFilter::make()
->hidden(self::isTableFilteredByDiscordUser(...)),
], layout: Enums\FiltersLayout::Dropdown)
->filtersTriggerAction(
fn (Action $action) => $action
->button()
->label('Filter'),
)
->filtersFormColumns(4)
->actions([
InfoAction::make(),
EditAction::make()
->label('')
->extraAttributes(['title' => 'Edit'])
->closeModalByClickingAway(false),
DeleteAction::make()->label('')->extraAttributes(['title' => 'Delete']),
RestoreAction::make()
->disabled(self::isDiscordUserTrashed(...))
->label('')
->iconButton()
->color('danger')
->iconSize(IconSize::Small)
->extraAttributes(
fn (Remainder $remainder) =>
self::isDiscordUserTrashed($remainder)
? ['title' => 'Cannot Restore']
: ['title' => 'Restore']
),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make()
->after(function (Actions\DeleteBulkAction $action) {
$action->redirect('/admin/remainders');
}),
ForceDeleteBulkAction::make(),
RestoreBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\UserResource\Services\UserResourceForm;
use App\Filament\Resources\UserResource\Services\UserResourceInfoList;
use App\Filament\Resources\UserResource\Services\UserResourceTable;
use App\Filament\Resources\UserResource\Pages;
use App\Models\User;
use Filament\Forms\Form;
use Filament\Infolists\Infolist;
use Filament\Resources\Resource;
use Filament\Tables\Table;
class UserResource extends Resource
{
protected static ?string $model = User::class;
protected static ?string $navigationIcon = 'heroicon-o-user';
protected static ?string $navigationGroup = 'Admin';
public static function form(Form $form): Form
{
return UserResourceForm::form($form);
}
//---------------------------------------------------------------------------------------------------------------
public static function table(Table $table): Table
{
return UserResourceTable::table($table);
}
//---------------------------------------------------------------------------------------------------------------
public static function infolist(Infolist $infolist): Infolist
{
return UserResourceInfoList::infolist($infolist);
}
//---------------------------------------------------------------------------------------------------------------
public static function getRelations(): array
{
return [
//
];
}
//---------------------------------------------------------------------------------------------------------------
public static function getPages(): array
{
return [
'index' => Pages\ListUsers::route('/'),
//'create' => Pages\CreateUser::route('/create'),
//'view' => Pages\ViewUser::route('/{record}'),
'show' => Pages\ViewUser::route('/{record}'),
'edit' => Pages\EditUser::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Resources\Pages\CreateRecord;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Model;
class EditUser extends EditRecord
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
//Actions\ViewAction::make(),
//Actions\DeleteAction::make(),
];
}
public function handleRecordUpdate(Model $record, array $data): Model
{
// filter out null values
$changes = array_filter($data);
$record->update($changes);
return $record;
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListUsers extends ListRecords
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
class ViewUser extends ViewRecord
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make(),
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Filament\Resources\UserResource\Services;
use App\Models\User;
use Filament\Forms\Form;
use Filament\Forms;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Set;
use Filament\Resources\Pages\EditRecord;
use Tapp\FilamentTimezoneField\Forms\Components\TimezoneSelect;
final class UserResourceForm
{
//---------------------------------------------------------------------------------------------------------------
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->required(),
Forms\Components\TextInput::make('email')
->email()
->required(),
TimezoneSelect::make('timezone')
->searchable(),
Forms\Components\DateTimePicker::make('email_verified_at')
->default(now())
->native(false)
->suffixAction(
Action::make('now')
->icon('heroicon-o-clock')
->name('now')
->action(function (DateTimePicker $component, Set $set) {
$set($component->getName(), now()->format($component->getFormat()));
})
->visible(
fn (User $user, $livewire): bool =>
$livewire instanceof EditRecord
&& !$user->isEmailVerified()
)
),
])
;
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Filament\Resources\UserResource\Services;
use Filament\Infolists\Infolist;
use Filament\Infolists\Components\Section;
use Filament\Infolists\Components\TextEntry;
final class UserResourceInfoList
{
public static function infolist(Infolist $infolist): Infolist
{
return $infolist
->schema([
Section::make('User')
->icon('heroicon-o-users')
->iconColor('info')
->description('The common data of the user.')
->columns(2)
->compact()
->schema([
TextEntry::make('name')->label('Name'),
TextEntry::make('email')->label('Email'),
TextEntry::make('timezone')->label('Timezone'),
TextEntry::make('email_verified_at')->label('Verified')->placeholder('not verified'),
]),
]);
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Filament\Resources\UserResource\Services;
use App\Filament\Actions\InfoAction;
use App\Models\User;
use Filament\Tables;
use Filament\Tables\Table;
final class UserResourceTable
{
//---------------------------------------------------------------------------------------------------------------
/**
* Returns the url for the UserResource::ViewUser
*
* @param User $user The User to view
*
* @return string The url to the view
*
* @callback_function
*
*/
public static function getViewUserUrl(User $user): string
{
return route('filament.admin.resources.users.show', [
'record' => $user,
]);
}
//---------------------------------------------------------------------------------------------------------------
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable(),
Tables\Columns\TextColumn::make('email')
->searchable(),
Tables\Columns\TextColumn::make('email_verified_at')
->dateTime()
->placeholder('not verified')
->sortable(),
Tables\Columns\TextColumn::make('timezone')
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\TrashedFilter::make(),
])
->actions([
InfoAction::make(),
Tables\Actions\Action::make('View')
->label('')
->extraAttributes(['title' => 'View'])
->icon('heroicon-o-eye')
->url(self::getViewUserUrl(...)),
Tables\Actions\EditAction::make()
->label('')
->extraAttributes(['title' => 'Edit']),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Filament\Widgets\Admin;
use App\Models\Remainder;
//use Carbon\Carbon;
use Filament\Widgets\ChartWidget;
use Flowframe\Trend\Trend;
use Flowframe\Trend\TrendValue;
use Illuminate\Support\Carbon;
class RemainderAllChart extends ChartWidget
{
protected static ?string $heading = 'All Remainders due at';
protected static ?string $pollingInterval = '10s';
protected static ?int $sort = 3;
protected static string $color = 'info';
protected function getData(): array
{
$data = Trend::model(Remainder::class)
->dateColumn('due_at')
->between(
start: Remainder::getFirstDueAt()?->due_at ?? Carbon::now(),
end: Remainder::getLastDueAt()?->due_at ?? Carbon::now()->addMonth(),
)
->perMonth()
->count();
return [
'datasets' => [
[
'label' => 'Remainders',
'data' => $data->map(fn (TrendValue $value) => $value->aggregate),
'fill' => true,
'tension' => 1,
],
],
'labels' => $data->map(fn (TrendValue $value) => $value->date),
];
}
protected function getType(): string
{
return 'line';
}
protected function getOptions(): array
{
return [
'plugins' => [
'legend' => [
'display' => false,
],
],
'scales' => [
'y' => [
'min' => 0,
],
],
];
}
public function getDescription(): ?string
{
return 'The number of remainders due at per month.';
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Filament\Widgets\Admin;
use App\Models\Remainder;
use Filament\Widgets\ChartWidget;
use Flowframe\Trend\Trend;
use Flowframe\Trend\TrendValue;
use Illuminate\Support\Carbon;
class RemainderTodayChart extends ChartWidget
{
protected static ?string $heading = 'Todays Remainders due at';
protected static ?string $pollingInterval = '10s';
protected static ?int $sort = 2;
protected static string $color = 'success';
//protected int | string | array $columnSpan = 2;
//protected static ?string $maxHeight = '10rem';
protected function getData(): array
{
$data = Trend::model(Remainder::class)
->dateColumn('due_at')
->between(
start: Carbon::today(),
end: Carbon::today()->endOfDay(),
)
->perHour()
->count()
;
return [
'datasets' => [
[
'label' => 'Remainders',
'data' => $data->map(fn (TrendValue $value) => $value->aggregate),
'fill' => true,
'tension' => 0.5,
],
],
'labels' => $data->map(fn (TrendValue $value) => explode(' ', $value->date)[1]),
];
}
protected function getType(): string
{
return 'line';
}
protected function getOptions(): array
{
return [
'plugins' => [
'legend' => [
'display' => false,
],
],
'scales' => [
'y' => [
'min' => 0,
],
],
];
}
public function getDescription(): ?string
{
return 'The number of remainders due at today.';
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Filament\Widgets;
use App\Models\DiscordUser;
use App\Models\Remainder;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Number;
class StatsOverview extends BaseWidget
{
protected function getStats(): array
{
$discordUserCount = DiscordUser::count();
$remainderCount = Remainder::count();
$avarageRemainders = $discordUserCount === 0 ? 0 : $remainderCount / $discordUserCount;
return [
Stat::make('Discord users', Number::format($discordUserCount)),
Stat::make('Remainders', Number::format($remainderCount)),//abbreviate
Stat::make('Average remainders per user', Number::format($avarageRemainders, 2)),
];
}
}

View File

@@ -0,0 +1,18 @@
<?php
use App\Enums\ApiPermission;
/**
* Returns a ability filter of az ApiPermission case for 'auth:sanctum' 'ability' middleware.
*
* @param ApiPermission $ability The ability to generate a filter for
*
* @return string The ability string. Example: 'ability:auth-user'
*
* @see https://laravel.com/docs/11.x/sanctum#token-ability-middleware
*
*/
function ability(ApiPermission $ability): string
{
return "ability:{$ability->value}";
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Http\Controllers\Api\v1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\v1\UpdateDiscordUserRequest;
use Illuminate\Http\Request;
use App\Models\DiscordUser;
use App\Http\Resources\Api\v1\DiscordUserResource;
/**
* @group Discord User By snowflake Managment
*
* APIs to manage DiscordUser records.
*
* These endpoints can be used to identify/create DiscordUser records identified by the [snowflake](#snowflake) that already exists in the discord app.
*
*/
class DiscordUserBySnoflakeController extends Controller
{
/**
* Get the DiscordUser identified by the specified snowflake.
*
* Returns the [DiscordUser](#discorduser) record for the specified [snowflake](#snowflake), given in the url __discord_user_snowflake__ parameter.
*
* If it cannot be found, a [**404, Not Found**](#not-found-404) error is returned.
*
* @urlParam discord_user_snowflake string required A valid [snowflake](#snowflake). Example: 481398158916845568
* @response 200 scenario=success {"data":{"id":42,"snowflake":"481398158916845568","user_name":"bigfootmcfly","global_name":"BigFoot McFly","locale":"hu_HU","timezone":"Europe\/Budapest"}}
* @response 404 scenario="not found" {"message":"Not Found."}
*
*/
public function show(Request $request, DiscordUser $discordUser)
{
return DiscordUserResource::make($discordUser);
}
/**
* Get _OR_ Update/Create the DiscordUser identified by the specified snowflake.
*
* If the record specified by the url __discord_user_snowflake__ parameter exists, it will be updated with the data provided in the body of the request.
*
* If it does not exists, it will be created using the given data.
*
* Returns the **updated/created** [DiscordUser](#discorduser) record.
*
* If anything goes wrong, a [**422, Unprocessable Content**](#unprocessable-content-422) error with more details will be returned.
*
* @urlParam snowflake string required A valid [snowflake](#snowflake). Example: 481398158916845568
* @bodyParam snowflake string required A valid [snowflake](#snowflake). Example: 481398158916845568
* @bodyParam locale string A valid [locale](#locale). Example: en_GB
* @bodyParam user_name string The user_name registered in Discord. Example: bigfootmcfly
* @bodyParam global_name string The global_name registered in Discord. Example: BigFoot McFly
* @bodyParam avatar string The avatar url registered in Discord. No-example
* @bodyParam timezone string A valid [time zone](#timezone). Example: Europe/London
* @response 200 scenario=success {"data":{"id":42,"snowflake":"481398158916845568","user_name":"bigfootmcfly","global_name":"BigFoot McFly","locale":"en_GB","timezone":"Europe\/London"},"changes":{"locale":{"old":"hu_HU","new":"en_GB"},"timezone":{"old":"Europe\/Budapest","new":"Europe\/London"}}}
* @response 422 scenario="Unprocessable Content" {"errors":{"snowflake":["The snowflake field is required."]}}
*
*/
public function update(UpdateDiscordUserRequest $request, int $snowflake)
{
$statusCode = match (($original = DiscordUser::where('snowflake', $snowflake)->first()) === null) {
true => 201,
false => 200
};
//NOTE: needed for the changeed field list, may be null
$original = DiscordUser::where('snowflake', $snowflake)->first();
$discordUser = DiscordUser::updateOrCreate(['snowflake' => $snowflake], $request->validated());
return response([
'data' => (DiscordUserResource::make($discordUser)),
'changes' => $discordUser->getChangedValues($original, ['updated_at']),
], $statusCode);
}
}

View File

@@ -0,0 +1,145 @@
<?php
namespace App\Http\Controllers\Api\v1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\v1\StoreDiscordUserRequest;
use App\Http\Requests\Api\v1\UpdateDiscordUserRequest;
use App\Http\Requests\Api\v1\DeleteDiscordUserRequest;
use App\Http\Resources\Api\v1\DiscordUserResource;
use App\Models\DiscordUser;
use App\Actions\DeleteDiscordUserAction;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
/**
* @group Discord User Managment
*
* APIs to manage [DiscordUser](#discorduser) records.
*
* These endpoints can be used to Query/Update/Delete [DiscordUser](#discorduser) records.
*/
class DiscordUserController extends Controller
{
/**
* List DiscorUsers.
*
* Paginated list of [DiscordUser](#discorduser) records.
*
* @queryParam page_size int Items per page. Defaults to 100. Example: 25
* @queryParam page int Page to query. Defaults to 1. Example: 1
* @response 200 scenario=success {"data":[{"id":1,"snowflake":"481398158916845568","user_name":"bigfootmcfly","global_name":"BigFoot McFly","locale":"hu_HU","timezone":"Europe/Budapest"},{"id":6,"snowflake":"860046989130727450","user_name":"Teszt Elek","global_name":"holnap_is_teszt_elek","locale":"hu","timezone":"Europe/Budapest"},{"id":12,"snowflake":"112233445566778899","user_name":"Igaz Álmos","global_name":"almos#1244","locale":null,"timezone":null}],"meta":{"current_page":1,"from":1,"last_page":1,"per_page":10,"to":3,"total":3}}
*
* @param \Illuminate\Http\Request $request
* @return ResourceCollection
*/
public function index(Request $request)
{
return DiscordUserResource::collection(DiscordUser::paginate(perPage: $request->page_size ?? 100));
}
/**
* Create a new DiscordUser record.
*
* Creates a new [DiscordUser](#discorduser) record with the provided parameters.
*
* @bodyParam snowflake string required A valid [snowflake](#snowflake). Example: 481398158916845568
* @bodyParam locale string A valid [locale](#locale). Example: hu_HU
* @bodyParam user_name string The user_name registered in Discord. Example: bigfootmcfly
* @bodyParam global_name string The global_name registered in Discord. Example: BigFoot McFly
* @bodyParam avatar string The avatar url registered in Discord. No-example
* @bodyParam timezone string A valid [time zone](#timezone). Example: Europe/Budapest
* @response 200 scenario=success {"data":{"id":42,"snowflake":"481398158916845568","user_name":"bigfootmcfly","global_name":"BigFoot McFly","locale":"hu_HU","timezone":"Europe\/Budapest"}}
* @response 422 scenario="Unprocessable Content" {"errors":{"snowflake":["The snowflake has already been taken."]}}
*/
public function store(StoreDiscordUserRequest $request)
{
$discordUser = DiscordUser::create($request->validated());
return response(
content: [
'data' => DiscordUserResource::make($discordUser),
],
status: 201
);
}
/**
* Get the specified DiscordUser record.
*
* Returns the specified [DiscordUser](#discorduser) record.
*
* @urlParam id int required [DiscordUser](#discorduser) ID. Example: 42
* @response 200 scenario=success {"data":{"id":42,"snowflake":"481398158916845568","user_name":"bigfootmcfly","global_name":"BigFoot McFly","locale":"hu_HU","timezone":"Europe\/Budapest"}}
*
* @return DiscordUserResource
*
*/
public function show(DiscordUser $discordUser)
{
return DiscordUserResource::make($discordUser);
}
/**
* Update the specified DiscordUser record.
*
* Updates the specified [DiscordUser](#discorduser) with the supplied values.
*
* @urlParam id int required [DiscordUser](#discorduser) ID. Example: 42
* @bodyParam snowflake string required The snowflake of the [DiscordUser](#discorduser) to update. Example: 481398158916845568
* @bodyParam user_name string The user_name registered in Discord. No-example
* @bodyParam global_name string The global_name registered in Discord. No-example
* @bodyParam avatar string The avatar url registered in Discord. No-example
* @bodyParam locale string A valid locale. <a href="https://github.com/Nerdtrix/language-list/blob/main/language-list-json.json" target="_blank">Locale list (json)</a> No-example
* @bodyParam timezone string A valid [time zone](#timezone). Example: Europe/London
* @response 200 {"data":{"id":42,"snowflake":"481398158916845568","user_name":"bigfootmcfly","global_name":"BigFoot McFly","locale":"hu_HU","timezone":"Europe\/London"}}
* @response 422 scenario="Unprocessable Content" {"errors":{"snowflake":["Invalid snowflake"]}}
*/
public function update(UpdateDiscordUserRequest $request, DiscordUser $discordUser)
{
//NOTE: due to filament validation, the snowflake is not checked in the request, it must be checked here.
$snowflake = $request->input('snowflake');
if ($discordUser->snowflake !== $snowflake) {
return response(
content: [
"errors" => [
"snowflake" => [
"Invalid snowflake",
],
],
],
status: 422
);
}
$original = DiscordUser::where('snowflake', $snowflake)->first();
$discordUser->update($request->validated());
return response(
content: [
'data' => (DiscordUserResource::make($discordUser)),
'changes' => $discordUser->getChangedValues($original, ['updated_at']),
],
status: 200
);
}
/**
* Remove the specified DiscordUser record.
*
* Removes the specified [DiscordUser](#discorduser) record **with** all the [Remainder](#remainder) records belonging to it.
*
* @urlParam id int required [DiscordUser](#discorduser) ID. Example: 42
* @bodyParam snowflake string required The snowflake of the [DiscordUser](#discorduser) to delete. Example: 481398158916845568
* @response 204
*
* @param \App\Http\Requests\Api\v1\DeleteDiscordUserRequest $request
* @param \App\Models\DiscordUser $discordUser
*
*/
public function destroy(DeleteDiscordUserRequest $request, DiscordUser $discordUser)
{
return DeleteDiscordUserAction::run($discordUser);
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace App\Http\Controllers\Api\v1;
use App\Actions\CreateRemainderAction;
use App\Actions\DeleteRemainderAction;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\v1\DeleteRemainderRequest;
use App\Http\Requests\Api\v1\StoreRemainderRequest;
use App\Http\Requests\Api\v1\UpdateRemainderRequest;
use App\Http\Resources\Api\v1\RemainderResource;
use App\Models\DiscordUser;
use App\Models\Remainder;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
/**
* @group Remainder Managment
*
* APIs to manage Remainders records.
*
* These endpoints can be used to Query/Update/Delete [Remainder](#remainder) records.
*
*/
class DiscordUserRemaindersController extends Controller
{
/**
* List of Remainder records.
*
* Paginated list of [Remainder](#remainder) records belonging to the specified [DiscordUser](#discorduser).
*
* @urlParam discord_user_id int required [DiscordUser](#discorduser) ID. Example: 42
* @queryParam page_size int Items per page. Defaults to 100. Example: 25
* @queryParam page int Page to query. Defaults to 1. Example: 1
* @response 200 scenario=success {"data":[{"id":38,"discord_user_id":42,"channel_id":null,"due_at":1736259300,"message":"Update todo list","status":"new","error":null},{"id":121,"discord_user_id":42,"channel_id":null,"due_at":1736259480,"message":"Water plants","status":"new","error":null}],"meta":{"current_page":1,"from":1,"last_page":1,"per_page":25,"to":2,"total":2}}
*
* @param DiscordUser $discordUser
* @param Request $request
* @return ResourceCollection
*/
public function index(DiscordUser $discordUser, Request $request): ResourceCollection
{
return RemainderResource::collection($discordUser->remainders()->paginate(perPage: $request->page_size ?? 100));
}
/**
* Create a new Remainder record.
*
* Creates a new [Remainder](#remainder) record with the provided parameters.
*
* @urlParam discord_user_id int required [DiscordUser](#discorduser) ID. Example: 42
*
* @bodyParam due_at string required The "Due at" time ([timestamp](#timestamp)) of the remainder Example: 1732550400
* @bodyParam message string required The message to send to the discord user. Example: Check maintance results!
* @bodyParam channel_id string The [snowflake](#snowflake) of the channel to send the remainder to. No-example
*
* @response 200 {"data":{"id":18568,"discord_user_id":42,"channel_id":null,"due_at":1732550400,"message":"Check maintance results!","status":"new","error":null}}
*
*/
public function store(StoreRemainderRequest $request, DiscordUser $discordUser)
{
return CreateRemainderAction::run($request, $discordUser);
}
/**
* Update the specified Remainder record.
*
* Updates the specified [Remainder](#remainder) record with the provided parameters.
*
* @urlParam discord_user_id int required [DiscordUser](#discorduser) ID. Example: 42
* @urlParam id int required [Remainder](#remainder) ID. Example: 18568
* @bodyParam due_at string The "Due at" time ([timestamp](#timestamp)) of the remainder. No-example
* @bodyParam message string The message to send to the discord user. No-example
* @bodyParam channel_id string The [snowflake](#snowflake) of the channel to send the remainder to. No-example
* @bodyParam error string Error description in case of failure. Example: Unknow user
* @bodyParam status string Status of the [Remainder](#remainder).
*
* For possible values see: [RemainderStatus](#remainderstatus) Example: failed
*
* @response 200 {"data":{"id":18568,"discord_user_id":42,"channel_id":null,"due_at":1732550400,"message":"Check maintance results!","status":"failed","error":"Unknow user"},"changes":{"status":{"old":"new","new":"failed"},"error":{"old":null,"new":"Unknow user"}}}
*
*/
public function update(UpdateRemainderRequest $request, DiscordUser $discordUser, Remainder $remainder)
{
$original = Remainder::find($remainder->id)->first();
$remainder->update($request->validated());
return response(
content: [
'data' => (RemainderResource::make($remainder)),
'changes' => $remainder->getChangedValues($original, ['updated_at']),
],
status: 200
);
}
/**
* Remove the specified Remainder record.
*
* Removes the specified [Remainder](#remainder) record with the provided parameters.
*
* @urlParam discord_user_id int required [DiscordUser](#discorduser) ID. Example: 42
* @urlParam id int required [Remainder](#remainder) ID. Example: 18568
* @bodyParam snowflake string required The [snowflake](#snowflake) of the DiscordUser of the Remainder to delete. Example: 481398158916845568
* @response 204
*
*/
public function destroy(DeleteRemainderRequest $request, DiscordUser $discordUser, Remainder $remainder)
{
return DeleteRemainderAction::run($remainder);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\Api\v1;
use App\Http\Controllers\Controller;
use App\Http\Resources\Api\v1\RemainderResource;
use App\Models\Remainder;
use Carbon\Carbon;
/**
* @group Remainder by DueAt Managment
*
* API to get Remainder records.
*
* This endpoint can be used to Query the actual [Remainder](#remainder) records.
*
*/
class RemainderByDueAtController extends Controller
{
/**
* Returns all the "actual" reaminders for the given second.
*
* @urlParam due_at int required The time ([timestamp](#timestamp)) of the requested remainders. Example: 1735685999
* @queryParam page_size int Items per page. Defaults to 100. Example: 25
* @queryParam page int Page to query. Defaults to 1. Example: 1
* @response 200 scenario=success {"data":[{"id":56,"discord_user_id":42,"channel_id":null,"due_at":1735685999,"message":"Update conatiner registry!","status":"new","error":null},{"id":192,"discord_user_id":47,"channel_id":null,"due_at":1735685999,"message":"Get some milk","status":"new","error":null}],"meta":{"current_page":1,"from":1,"last_page":1,"per_page":100,"to":2,"total":2}}
*/
public function __invoke(int|string $due_at)
{
return RemainderResource::collection(Remainder::query()
->where('due_at', Carbon::createFromTimestamp($due_at))
->where('status', 'new')
->paginate(perPage: $request->page_size ?? 100));
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class AuthenticatedSessionController extends Controller
{
/**
* Display the login view.
*/
public function create(): View
{
return view('auth.login');
}
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(route('dashboard', absolute: false));
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class ConfirmablePasswordController extends Controller
{
/**
* Show the confirm password view.
*/
public function show(): View
{
return view('auth.confirm-password');
}
/**
* Confirm the user's password.
*/
public function store(Request $request): RedirectResponse
{
if (! Auth::guard('web')->validate([
'email' => $request->user()->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(route('dashboard', absolute: false));
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EmailVerificationNotificationController extends Controller
{
/**
* Send a new email verification notification.
*/
public function store(Request $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false));
}
$request->user()->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EmailVerificationPromptController extends Controller
{
/**
* Display the email verification prompt.
*/
public function __invoke(Request $request): RedirectResponse|View
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(route('dashboard', absolute: false))
: view('auth.verify-email');
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\View\View;
class NewPasswordController extends Controller
{
/**
* Display the password reset view.
*/
public function create(Request $request): View
{
return view('auth.reset-password', ['request' => $request]);
}
/**
* Handle an incoming new password request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'token' => ['required'],
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function ($user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
return $status == Password::PASSWORD_RESET
? redirect()->route('login')->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class PasswordController extends Controller
{
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validateWithBag('updatePassword', [
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back()->with('status', 'password-updated');
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\View\View;
class PasswordResetLinkController extends Controller
{
/**
* Display the password reset link request view.
*/
public function create(): View
{
return view('auth.forgot-password');
}
/**
* Handle an incoming password reset link request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'email' => ['required', 'email'],
]);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$status = Password::sendResetLink(
$request->only('email')
);
return $status == Password::RESET_LINK_SENT
? back()->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\View\View;
class RegisteredUserController extends Controller
{
/**
* Display the registration view.
*/
public function create(): View
{
return view('auth.register');
}
/**
* Handle an incoming registration request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
event(new Registered($user));
Auth::login($user);
return redirect(route('dashboard', absolute: false));
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ProfileUpdateRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Illuminate\View\View;
class ProfileController extends Controller
{
/**
* Display the user's profile form.
*/
public function edit(Request $request): View
{
return view('profile.edit', [
'user' => $request->user(),
]);
}
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
return Redirect::route('profile.edit')->with('status', 'profile-updated');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validateWithBag('userDeletion', [
'password' => ['required', 'current_password'],
]);
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return Redirect::to('/');
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class HardenHeaders
{
protected $headersToRemove = [
'X-Powered-By',
'Server',
];
protected $headersToAdd = [
'X-Content-Type-Options' => 'nosniff',
'Strict-Transport-Security' => 'max-age:63072000; includeSubDomains; preload',
'X-Answer' => '42',
//'X-Powered-By' => 'NCSA HTTPd v1.5',
//'X-Powered-By' => 'CERN httpd/3.0A',
];
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$this
->removeHeaders($response)
->addHeaders($response);
return $response;
}
private function removeHeaders(Response $response): self
{
foreach ($this->headersToRemove as $header) {
header_remove($header); // remove header already stored by php
$response->headers->remove($header); // remove header managed by laravel
}
return $this;
}
private function addHeaders(Response $response): self
{
foreach ($this->headersToAdd as $header => $value) {
$response->headers->set($header, $value);
}
return $this;
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Removes web links from api paginated response.
*/
class StripPaginationInfo
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$content = json_decode($response->getContent(), true);
unset($content['links']);
unset($content['meta']['links']);
unset($content['meta']['path']);
$response->setContent(json_encode($content));
return $response;
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Requests\Api\v1;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exceptions\HttpResponseException;
class DeleteDiscordUserRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; //maybe add guard (route is already guarded...)
}
protected function failedValidation(Validator $validator): void
{
throw new HttpResponseException(response()->json(['errors' => $validator->errors()], 422));
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
/**
* @var string snowflake The snowflake of the DiscordUser
* NOTE: the failsafe is for Scribe.
* @see: https://scribe.knuckles.wtf/laravel/troubleshooting#some-weird-behaviour-when-using-formrequests
* @see 'git show eb10344ce1'
*/
$snowflake = request('discord_user')?->snowflake ?? 0;
return [
'snowflake' => [
'required',
'snowflake',
Rule::in($snowflake),
],
];
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Http\Requests\Api\v1;
use App\Models\DiscordUser;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exceptions\HttpResponseException;
class DeleteRemainderRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; //maybe add guard (route is already guarded...)
}
protected function failedValidation(Validator $validator): void
{
throw new HttpResponseException(response()->json(['errors' => $validator->errors()], 422));
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
/**
* @var string snowflake The snowflake of the DiscordUser
* NOTE: the failsafe is for Scribe.
* @see: https://scribe.knuckles.wtf/laravel/troubleshooting#some-weird-behaviour-when-using-formrequests
* @see 'git show eb10344ce1'
*/
$snowflake = request('discord_user')?->snowflake ?? 0;
return [
'snowflake' => [
'required',
'string',
'digits_between:18,19',
Rule::in($snowflake),
'exclude',
],
];
}
/**
* Get the "after" validation callables for the request.
*/
public function after(): array
{
return [
function (Validator $validator) {
if (request()->remainder->discord_user_id != request()->discord_user->id) {
$validator->errors()->add(
'remainder',
'Remainder does not belong to the discord user!',
);
}
},
];
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Http\Requests\Api\v1;
use App\Traits\FilamentFormRequest;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exceptions\HttpResponseException;
class StoreDiscordUserRequest extends FormRequest
{
use FilamentFormRequest;
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; //maybe add guard (route is already guarded...)
}
protected function failedValidation(Validator $validator): void
{
throw new HttpResponseException(response()->json(['errors' => $validator->errors()], 422));
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'snowflake' => 'required|snowflake|unique:discord_users,snowflake',
'user_name' => 'string|nullable',
'global_name' => 'string|nullable',
'avatar' => 'string|nullable',
'locale' => [
Rule::in(LOCALES),
'nullable',
],
'timezone' => 'string|nullable|timezone:all',
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests\Api\v1;
use App\Traits\FilamentFormRequest;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exceptions\HttpResponseException;
class StoreRemainderRequest extends FormRequest
{
use FilamentFormRequest;
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
protected function failedValidation(Validator $validator): void
{
throw new HttpResponseException(response()->json(['errors' => $validator->errors()], 422));
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'due_at' => 'required',
'message' => 'required|string',
'channel_id' => 'snowflake|nullable',
];
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Requests\Api\v1;
use App\Traits\FilamentFormRequest;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exceptions\HttpResponseException;
class UpdateDiscordUserRequest extends FormRequest
{
use FilamentFormRequest;
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; //maybe add guard (route is already guarded...)
}
protected function failedValidation(Validator $validator): void
{
throw new HttpResponseException(response()->json(['errors' => $validator->errors()], 422));
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'snowflake' => [
'required',
'string',
'snowflake',
'exclude',
],
'user_name' => 'string|nullable',
'global_name' => 'string|nullable',
'avatar' => 'string|nullable',
'locale' => [
Rule::in(LOCALES),
'nullable',
],
'timezone' => 'string|nullable|timezone:all',
];
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Http\Requests\Api\v1;
use App\Enums\RemainderStatus;
use Carbon\Carbon;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exceptions\HttpResponseException;
class UpdateRemainderRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
protected function failedValidation(Validator $validator): void
{
throw new HttpResponseException(response()->json(['errors' => $validator->errors()], 422));
}
protected function prepareForValidation(): void
{
if (!$this->request->has('due_at')) {
return;
}
// Convert from timestamp to Carbon object because laravel lacks timestamp validation
$this->merge([
'due_at' => Carbon::createFromTimestamp($this->request->get('due_at')),
]);
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'due_at' => 'nullable|date|after:now',//'after:tomorrow'
'message' => 'string',
'channel_id' => 'digits_between:18,19|nullable',
'status' => [
Rule::in(RemainderStatus::values()),
'nullable',
],
'error' => 'nullable|string',
];
}
/**
* Get the "after" validation callables for the request.
*/
public function after(): array
{
return [
function (Validator $validator) {
if (request()->remainder->discord_user_id != request()->discord_user->id) {
$validator->errors()->add(
'remainder',
'Remainder does not belong to the discord user!',
);
}
},
];
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\Rule|array|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
/**
* Attempt to authenticate the request's credentials.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
/**
* Ensure the login request is not rate limited.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the rate limiting throttle key for the request.
*/
public function throttleKey(): string
{
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\Rule|array|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique(User::class)->ignore($this->user()->id)],
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Resources\Api\v1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class DiscordUserRemainderResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return array_merge(
DiscordUserResource::make($this)->toArray($request),
['remainders' => $this->remainders->toArray($request)]
);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Resources\Api\v1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class DiscordUserResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'snowflake' => $this->snowflake,
'user_name' => $this->user_name,
'global_name' => $this->global_name,
'locale' => $this->locale,
'timezone' => $this->timezone,
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Resources\Api\v1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class RemainderResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'discord_user_id' => $this->discord_user_id,
'channel_id' => $this->channel_id,
'due_at' => $this->due_at->timestamp,
'message' => $this->message,
'status' => $this->status,
'error' => $this->error,
...(
$request->has('withDiscordUser')
? ['discord_user' => new DiscordUserResource($this->discordUser)]
: []
),
//NOTE: the above could be done with this:
// it is easier to read, but then, the relation must be loaded before this call, so the Request must be handled before...
/*
'discord_user' => whenLoaded(new DiscordUserResource($this->discordUser)),
*/
];
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Livewire;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Components\Section;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Livewire\Component;
use Illuminate\Contracts\View\View;
use Joaopaulolndev\FilamentEditProfile\Concerns\HasSort;
use Joaopaulolndev\FilamentEditProfile\Concerns\HasUser;
use Tapp\FilamentTimezoneField\Forms\Components\TimezoneSelect;
class ProfileTimezoneComponent extends Component implements HasForms
{
use InteractsWithForms;
use HasSort;
use HasUser;
public ?array $data = [];
protected static int $sort = 0;
public function mount(): void
{
$user = $this->getUser();
$this->form->fill(['timezone' => $user->timezone]);
}
public function form(Form $form): Form
{
return $form
->schema([
Section::make('Update Timezone')
->aside()
->description('Set/Update your Timezone.')
->schema([
TimezoneSelect::make('timezone')
->searchable(),
]),
])
->statePath('data');
}
public function save(): void
{
$data = $this->form->getState();
$user = $this->getUser();
$user->update($data);
Notification::make()
->title('Your profile information has been saved successfully.')
->success()
->send();
}
public function render(): View
{
return view('livewire.profile-timezone-component');
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace App\Models;
use App\Traits\HasChanges;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Builder;
use Laravel\Sanctum\HasApiTokens;
class DiscordUser extends Model
{
use HasFactory, SoftDeletes, HasApiTokens, HasChanges;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'snowflake',
'user_name',
'global_name',
'avatar',
'locale',
'timezone',
];
protected $hidden = [
'avatar',
];
/**
* retuns only the non-trashed remainders for the DiscordUser
*/
public function remainders()
{
return $this->hasMany(Remainder::class);//->withTrashed();
}
/**
* retuns only the trashed remainders for the DiscordUser
*/
public function trashedRemainders()
{
return $this->hasMany(Remainder::class)->onlyTrashed();
}
/**
* retuns all remainders for the DiscordUser
*/
public function allRemainders()
{
return $this->hasMany(Remainder::class)->withTrashed();
}
/**
* retuns the count of non-trashed remainders of the DiscordUser
*/
public function getRemainderCountAttribute(): int
{
return cache()->remember(
key: 'DiscordUserRemainderCount_'.$this->id,
ttl: 3,
callback: fn () => $this->remainders_count ?? $this->remainders()->count()
);
}
/**
* retuns the count of trashed remainders of the DiscordUser
*/
public function getTrashedRemainderCountAttribute(): int
{
return cache()->remember(
key: 'DiscordUserTrashedRemainderCount_'.$this->id,
ttl: 1,
callback: fn () => $this->trashedRemainders()->count()
);
}
/**
* retuns the count of all remainders of the DiscordUser
*/
public function getAllRemainderCountAttribute(): int
{
return cache()->remember(
key: 'DiscordUserAllRemainderCount_'.$this->id,
ttl: 3,
callback: fn () => $this->remainders()->withTrashed()->count()
);
}
/**
* Adds the remainders count to the model
* @param Builder $builder
* @return void
*/
public function scopeRemainderCount(Builder $builder): void
{
$builder->withCount('remainders');
}
/**
* Permanantly delete the DiscordUser and all its remainders
*
* The records are not protected by soft-delete
*
* @return [type]
*
*/
public function permanentDelete()
{
$this->allRemainders()->forceDelete();
$this->forceDelete();
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace App\Models;
use App\Enums\RemainderStatus;
use App\Traits\HasChanges;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
class Remainder extends Model
{
use HasFactory, SoftDeletes, HasChanges;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'discord_user_id',
'channel_id',
'due_at',
'message',
'status',
'error',
];
protected $casts = [
'status' => RemainderStatus::class,
'due_at' => 'datetime',
];
/**
* retuns the owner of the remainder
*/
public function discordUser(): Relation|EloquentBuilder
{
return $this->belongsTo(DiscordUser::class)->withTrashed();
}
/**
* Adds the channel_id to the model
*
* @param EloquentBuilder $query
* @param bool $present
*
* @return void
*
*/
public function scopeChannel(EloquentBuilder $query, bool $present = true): void
{
$query->whereNull('channel_id', not: $present);
}
/**
* Returns the first active remainder
*
* @return self|null the first remainder if found, null if there are no active remainders
*
*/
public static function getFirstDueAt(): self|null
{
return cache()->remember(
key: 'RemainderFirstDueAt',
ttl: 1,
callback: fn () => self::orderBy('due_at')->first()
);
}
/**
* Returns the last active remainder
*
* @return self|null the last remainder if found, null if there are no active remainders
*
*/
public static function getLastDueAt(): self|null
{
return cache()->remember(
key: 'RemainderLastDueAt',
ttl: 1,
callback: fn () => self::orderByDesc('due_at')->first()
);
}
/**
* Checks, if the remainder has failed
*
* @return bool true if the remainder failed, false otherwise
*
*/
public function isFailed(): bool
{
return $this->status === RemainderStatus::FAILED;
}
/**
* Checks, if the remainder is overdue
*
* @return bool true if the remainder is overdue, false otherwise
*
*/
public function isOverDue(): bool
{
return
$this->due_at < Carbon::now()
&& $this->status !== RemainderStatus::FINISHED
&& $this->status !== RemainderStatus::FAILED
;
}
}

65
src/app/Models/User.php Normal file
View File

@@ -0,0 +1,65 @@
<?php
namespace App\Models;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable implements FilamentUser
{
use HasFactory, Notifiable, HasApiTokens, SoftDeletes;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'name',
'email',
'password',
'timezone',
'email_verified_at',
];
/**
* The attributes that should be hidden for serialization.
*
* @var array<int, string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function isEmailVerified(): bool
{
return $this->email_verified_at !== null;
}
public function canAccessPanel(Panel $panel): bool
{
//TODO: this is temporary and should be replaced by role managment...
return $this->id === 1;
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Policies;
use App\Models\DiscordUser;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class DiscordUserPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, DiscordUser $discordUser): bool
{
return true;
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return true;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, DiscordUser $discordUser): bool
{
return true;
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, DiscordUser $discordUser): bool
{
return true;
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, DiscordUser $discordUser): bool
{
return true;
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, DiscordUser $discordUser): bool
{
return true;
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Providers;
use App\Validators\SnowflakeValidator;
use Filament\Infolists\Components\TextEntry;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
// load telescope only if not in production
if ($this->app->environment('local')) {
$this->app->register(\Laravel\Telescope\TelescopeServiceProvider::class);
$this->app->register(TelescopeServiceProvider::class);
}
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
// setting default color for Infolist textEntry objects
TextEntry::configureUsing(function (TextEntry $textEntry): void {
$textEntry->color('info');
});
// register custom validator
SnowflakeValidator::registerValidator();
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace App\Providers\Filament;
use App\Enums\ApiPermission;
use App\Livewire\ProfileTimezoneComponent;
use CharrafiMed\GlobalSearchModal\GlobalSearchModalPlugin;
use Devonab\FilamentEasyFooter\EasyFooterPlugin;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Navigation\MenuItem;
use Filament\Pages;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\HtmlString;
use Illuminate\View\Middleware\ShareErrorsFromSession;
use Joaopaulolndev\FilamentEditProfile\FilamentEditProfilePlugin;
use Joaopaulolndev\FilamentEditProfile\Pages\EditProfilePage;
class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->default()
->sidebarCollapsibleOnDesktop()
->id('admin')
->path('admin')
->login()
->colors([
'primary' => Color::Amber,
'gray' => Color::Stone,
])
->viteTheme('resources/css/filament/admin/theme.css')
->brandLogo(fn () => view('filament.admin.logo'))
->favicon(asset('images/logo.png'))
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
->pages([
Pages\Dashboard::class,
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
->widgets([
//
])
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->brandName('Proxima')
//NOTE: only set this if the page is on that domain
//->domain('proxima.goliath.hu')
->globalSearchKeyBindings(['CTRL+ALT+S', 'command+option+s'])
->globalSearchFieldKeyBindingSuffix()
->authMiddleware([
Authenticate::class,
])
->plugins([
GlobalSearchModalPlugin::make()
->expandedUrlTarget(enabled: false),
FilamentEditProfilePlugin::make()
->shouldShowDeleteAccountForm(false)
->setIcon('heroicon-o-user')
->shouldShowBrowserSessionsForm(true)
->shouldRegisterNavigation(false)
->shouldShowSanctumTokens(
condition: true,
permissions: ApiPermission::toDescribedArray()
)
->customProfileComponents([
ProfileTimezoneComponent::class,
]),
//TODO: only add github if github credentials are found!!!
EasyFooterPlugin::make()
->footerEnabled()
->withFooterPosition('footer')
->withSentence(new HtmlString('<img class="non-grayscale" src="'.asset('images/icon-512.png').'" alt="Proxima Backend Logo" width="20" height="20"> Proxima Backend'))
->withGithub(showLogo: true, showUrl: true)
->withLoadTime()
->withLinks([
[
'title' => 'About',
'url' => 'https://proxima.goliath.hu',
],
[
'title' => 'Docs',
'url' => 'https://proxima.goliath.hu/assets/docs/index.html',
],
]),
])
->userMenuItems([
'profile' => MenuItem::make()
->label(fn () => Auth::user()->name)
->url(fn () => EditProfilePage::getUrl())
->icon('heroicon-m-user-circle'),
])
;
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Gate;
use Laravel\Telescope\IncomingEntry;
use Laravel\Telescope\Telescope;
use Laravel\Telescope\TelescopeApplicationServiceProvider;
class TelescopeServiceProvider extends TelescopeApplicationServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
Telescope::night();
$this->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) {
return in_array($user->email, [
//
]);
});
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Traits;
trait BackedEnumHelper
{
/**
* Returnbs the names of all casee
*
* @return array The names of all cases
*
*/
public static function names(): array
{
return array_column(self::cases(), 'name');
}
/**
* Returnbs the values of all casee
*
* @return array The values of all cases
*
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* Converts the enum to array
*
* @return array The key => value pairs of the cases
*
*/
public static function toArray(): array
{
return array_column(self::cases(), 'value', 'name');
}
/**
* Converts the enum to array with lower key values
*
* @return array The strtolower(key) => value pairs of the cases
*
* @todo this was a temporary fix, refactor it!
*/
public static function toSelectOptions(): array
{
return array_change_key_case(self::toArray(), CASE_LOWER);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Traits;
trait FilamentFormRequest
{
/**
* Converts complex rules to usable versions for filament
*
* @return array list of rules
*
*/
public static function asFilamentRules(): array
{
//NOTE: currently all special rules are removed from the project, this is here for compatibility
//NOTE: filament may not know the table name in a 'unique' filter, so a 'unique' should be converted to 'unique:table_name'
//NOTE: In:: rules should be converted to array ( '->__toString()' )
/* @phpstan-ignore new.static (Symfony package) */
return (new static())->rules();
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Traits;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
trait HasChanges
{
/**
* Returns the list of changed fields with old/new values of a Model
*
* @param Model|null $original the Model befor the changes applied for $this (if $this is just created, this will be null)
* @param array $hiddenFields list of fields to hide from teh result
*
* @return array
*
* result example:
* [
* 'name' => [
* 'old' => 'Old Shatterhand'
* 'new' => 'Lex Barker'
* ]
* ]
*
*/
public function getChangedValues(?Model $original = null, array $hiddenFields = []): array
{
$changes = [];
foreach ($this->getChanges() as $key => $value) {
if (in_array($key, $hiddenFields)) {
continue;
}
$isDateTime = $this?->$key && is_object($this?->$key) && $this->$key::class === Carbon::class;
$changes[$key] = [
'old' => $isDateTime ? $original?->$key->timestamp : $original?->$key,
'new' => $isDateTime ? Carbon::parse($value)->getTimestamp() : $value,
];
}
return $changes;
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Traits;
use App\Attributes\Description;
use Illuminate\Support\Str;
use ReflectionEnum;
trait HasEnumDescription
{
/**
* Returns the description of the current enum case
*
* @return string The description of the case if defined, the value as headline otherwise
*
*/
public function description(): string
{
$ref = new ReflectionEnum($this);
$case = $ref->getCase($this->name);
$attr = $case->getAttributes(Description::class);
if (count($attr) === 0) {
return Str::headline($this->value);
}
return $attr[0]->newInstance()->description;
}
/**
* Returns all the cases in 'value'=>'description' form
*
* @return array The value => description pairs of the cases
*
*/
public static function toDescribedArray(): array
{
//NOTE: the one-liner, a marvel of overengineering
return array_reduce(
array: self::cases(),
callback: fn (array $carry, self $case): array =>
$carry + [$case->value => $case->description()],
initial: []
);
//NOTE: the wise way... (yields the same result)
/*
$result = [];
foreach (self::cases() as $case) {
$result[$case->value] = $case->description();
}
return $result;
*/
}
}

Some files were not shown because too many files have changed in this diff Show More