! 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

20
.dockerignore Normal file
View File

@@ -0,0 +1,20 @@
src/vendor
src/database/database.*
src/database/sqlite/database.*
src/node_modules
src/.env
src/.env.example
src/.scribe
src/public/vendor/telescope
src/phpunit.xml
src/.php-cs-fixer.*
src/.editorconfig
src/vite.config.js
src/tailwind.config.js
src/postcss.config.js
src/package-lock.json
src/storage
volumes/
.git/
.git-crypt/
.github/

0
.gitattributes vendored Normal file
View File

67
.github/workflows/latest.yaml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: Build docker image
env:
PACKAGE_NAME: backend
PACKAGE_TAG: latest
run-name: ${{ github.actor }} is building the docker image
on:
push:
# tags:
# - v**
branches:
- main
- master
jobs:
build_docker_image:
runs-on: ubuntu-latest
steps:
- name: Checking out repository
uses: actions/checkout@v4
- name: Login to Goliath Container Registry
uses: docker/login-action@v3
with:
registry: ${{ vars.PACKAGE_REPOSITORY }}
username: ${{ secrets.PACKAGE_USER }}
password: ${{ secrets.PACKAGE_PASS }}
- name: Setup PHP
uses: "shivammathur/setup-php@v2"
- name: Setup Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Initializing packages
run: |
pushd src
npm install
composer install
popd
- name: Compiling assets
run: |
pushd src
npm run build
popd
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create Docker Buildx contex
run: docker buildx create --name goliath; docker buildx use goliath; docker buildx inspect --bootstrap;
- name: Building docker image
run: |
docker buildx build . \
--file build/nginx/Dockerfile \
--platform linux/amd64,linux/arm64 \
--tag ${{ vars.PACKAGE_REPOSITORY }}/${{ vars.PACKAGE_ORGANIZATION }}/${{ env.PACKAGE_NAME }}:${{ env.PACKAGE_TAG }} \
--provenance=false \
--sbom=false \
--push

66
.github/workflows/testing.yaml vendored Normal file
View File

@@ -0,0 +1,66 @@
name: Build docker image
env:
PACKAGE_NAME: backend
PACKAGE_TAG: testing
run-name: ${{ github.actor }} is building the docker image
on:
push:
tags:
- v**
branches:
- 'dev'
jobs:
build_docker_image:
runs-on: ubuntu-latest
steps:
- name: Checking out repository
uses: actions/checkout@v4
- name: Login to Goliath Container Registry
uses: docker/login-action@v3
with:
registry: ${{ vars.PACKAGE_REPOSITORY }}
username: ${{ secrets.PACKAGE_USER }}
password: ${{ secrets.PACKAGE_PASS }}
- name: Setup PHP
uses: "shivammathur/setup-php@v2"
- name: Setup Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Initializing packages
run: |
pushd src
npm install
composer install
popd
- name: Compiling assets
run: |
pushd src
npm run build
popd
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create Docker Buildx contex
run: docker buildx create --name goliath; docker buildx use goliath; docker buildx inspect --bootstrap;
- name: Building docker image
run: |
docker buildx build . \
--file build/nginx/Dockerfile \
--platform linux/amd64,linux/arm64 \
--tag ${{ vars.PACKAGE_REPOSITORY }}/${{ vars.PACKAGE_ORGANIZATION }}/${{ env.PACKAGE_NAME }}:${{ env.PACKAGE_TAG }} \
--provenance=false \
--sbom=false \
--push

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
volumes/debug/*
volumes/env/*

9
LICENSE Normal file
View File

@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2025 proxima
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

108
build/nginx/Dockerfile Normal file
View File

@@ -0,0 +1,108 @@
ARG ALPINE_VERSION=3.21
FROM alpine:${ALPINE_VERSION}
# initialize arguments
# php versions: 82, 83, 84
ARG PHP_VERSION=84
ARG USER_ID=1000
ARG GROUP_ID=1000
ARG WORKDIR=/app/Backend
ARG PHP_FPM_LISTEN_DIR=/app/run
ARG LOG_DIR=/app/log
ENV BUILD_CONTENTS=build/nginx
ENV PHP_INI_DIR=/etc/php${PHP_VERSION}
# install dependencies
RUN apk add \
bash \
supervisor \
nginx \
icu-data-full \
php${PHP_VERSION} \
php${PHP_VERSION}-fpm \
php${PHP_VERSION}-curl \
php${PHP_VERSION}-intl \
php${PHP_VERSION}-dom \
php${PHP_VERSION}-fileinfo \
php${PHP_VERSION}-iconv \
php${PHP_VERSION}-mbstring \
php${PHP_VERSION}-openssl \
php${PHP_VERSION}-pdo \
php${PHP_VERSION}-pdo_sqlite \
php${PHP_VERSION}-phar \
php${PHP_VERSION}-opcache \
php${PHP_VERSION}-session \
php${PHP_VERSION}-simplexml \
php${PHP_VERSION}-sqlite3 \
php${PHP_VERSION}-tokenizer \
php${PHP_VERSION}-xml \
php${PHP_VERSION}-xmlreader \
php${PHP_VERSION}-xmlwriter \
php${PHP_VERSION}-zip \
&& ln -s /usr/bin/php${PHP_VERSION} /usr/bin/php
# add user and group for the app
RUN addgroup -g ${GROUP_ID} bot \
&& adduser -u ${USER_ID} -G bot -D bot
# install composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# copy the custom php.ini
COPY ${BUILD_CONTENTS}/config/99_custom_php.ini ${PHP_INI_DIR}/conf.d/99_custom_php.ini
# copy the custom fpm.ini
COPY ${BUILD_CONTENTS}/config/www.conf ${PHP_INI_DIR}/php-fpm.d/www.conf
# copy nginx config
COPY ${BUILD_CONTENTS}/config/nginx.conf /etc/nginx/nginx.conf
# copy nginx server config
COPY ${BUILD_CONTENTS}/config/default.conf /etc/nginx/http.d/default.conf
# copy supervisord config
COPY --chown=${USER_ID}:${GROUP_ID} ${BUILD_CONTENTS}/config/supervisord.conf /etc/supervisord.conf
# set php version for supervisord
RUN sed -i.bak "s/command=php-fpm -F/command=php-fpm${PHP_VERSION} -F/g" /etc/supervisord.conf
# setup workdir and php-fpm listener dir
RUN mkdir -p ${WORKDIR} ${PHP_FPM_LISTEN_DIR} ${LOG_DIR}
# change ownership of folders to the user
RUN chown -R ${USER_ID}:${GROUP_ID} ${WORKDIR} ${PHP_FPM_LISTEN_DIR} ${LOG_DIR}
# set tue workdir
WORKDIR ${WORKDIR}
# copy the application
COPY --chown=${USER_ID}:${GROUP_ID} src/ .
# change to the user
USER ${USER_ID}:${GROUP_ID}
# initialize composer
RUN mkdir -p storage/framework/views \
&& composer install \
--no-interaction \
--no-dev \
--optimize-autoloader
#NOTE: this is a messy hack, there is no guaranty it wont be changed, use this only until a better way is found
RUN sed -i.bak "s/->modalWidth('md')/->modalWidth('xl')/g" vendor/joaopaulolndev/filament-edit-profile/src/Livewire/SanctumTokens.php
# copy the entrypoint script
COPY --chown=${USER_ID}:${GROUP_ID} --chmod=0555 ${BUILD_CONTENTS}/entrypoint.sh .
# define the storage volume
VOLUME [ "/app/Backend/storage" ]
VOLUME [ "/app/Backend/database/sqlite" ]
# define the exposed port
EXPOSE 9000
# start the app
CMD ["/usr/bin/supervisord"]

53
build/nginx/build.sh Executable file
View File

@@ -0,0 +1,53 @@
#!/bin/bash
PACKAGE_REPOSITORY=gitea.goliath.hu/packages/discord-bot-goliath-backend
BUILD_TAG=nginx
PACKAGE_TAG=nginx
# ---------------------------------------------------------------------------
echo Building "${BUILD_TAG}" package with "${PACKAGE_TAG}" tag...
echo
# ---------------------------------------------------------------------------
echo Changing to project root directory...
pushd ../.. > /dev/null
# ---------------------------------------------------------------------------
echo Building assets...
pushd src > /dev/null
npm run build
popd > /dev/null
# ---------------------------------------------------------------------------
echo Determining tag name...
branch=$(git branch --show-current)
tag=${PACKAGE_TAG:-temp}
#[[ $branch == dev ]] && tag=testing
#[[ $branch == master ]] && tag=latest
#[[ $branch == features/nginx-server ]] && tag=nginx-joint
# ---------------------------------------------------------------------------
echo Building image...
docker build \
--tag ${PACKAGE_REPOSITORY}:$tag \
--build-arg GROUP_ID=$(id -g) \
--build-arg USER_ID=$(id -u) \
--file build/${BUILD_TAG}/Dockerfile \
.
# --progress=plain \
# --push \
# ---------------------------------------------------------------------------
echo Changing back to build directory...
popd > /dev/null
# ---------------------------------------------------------------------------
echo Done.

View File

@@ -0,0 +1,38 @@
; Excludes arguments from stack traces generated from exceptions.
; Default Value: Off
+zend.exception_ignore_args = On
; Allows setting the maximum string length in an argument of a stringified stack trace
; to a value between 0 and 1000000.
; Default Value: 15
; In production, it is recommended to set this to 0 to reduce the output
; of sensitive information in stack traces.
zend.exception_string_param_max_len = 0
; Set the error reporting level.
; Defult Value: E_ALL
; https://php.net/error-reporting
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
; This directive controls whether or not and where PHP will output errors,
; notices and warnings too. Error output is very useful during development, but
; it should never be used on production system.
; https://php.net/display-errors
display_errors = Off
; The display of errors which occur during PHP's startup sequence are handled
; separately from display_errors. It's strongly recommended to keep
; display_startup_errors off, except for debugging.
; https://php.net/display-startup-errors
display_startup_errors = Off
; Mysql is not used right now, but in case we change database prowider later...
; Enable / Disable collection of memory usage statistics by mysqlnd which can be
; used to tune and monitor MySQL operations.
mysqlnd.collect_memory_statistics = Off
; Records communication from all extensions using mysqlnd to the specified log
; file.
; https://php.net/zend.assertions
zend.assertions = -1

View File

@@ -0,0 +1,34 @@
# This is a default site configuration which will simply return 404, preventing
# chance access to any other virtualhost.
server {
listen 9000 default_server;
listen [::]:9000 default_server;
# server_name proxima.goliath.hu;
access_log /app/log/nginx-access.log;
error_log /app/log/nginx-error.log error;
location ~ \.php$ {
root /app/Backend/public/;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/app/run/php-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi.conf;
}
location / {
root /app/Backend/public/;
try_files $uri $uri/ /index.php?$args;
index index.php index.html index.htm;
}
# You may need this to prevent return 404 recursion.
location = /404.html {
internal;
}
}

View File

@@ -0,0 +1,53 @@
#user bot;
worker_processes auto;
#error_log /app/log/nginx.error.log warn;
error_log stderr warn;
pid /app/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include mime.types;
# Threat files with a unknown filetype as binary
default_type application/octet-stream;
# Define custom log format to include reponse times
log_format main_timed '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" '
'$request_time $upstream_response_time $pipe $upstream_cache_status';
access_log /dev/stdout main_timed;
error_log /dev/stderr notice;
keepalive_timeout 65;
# Write temporary files to /tmp so they can be created as a non-privileged user
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp_path;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
# Hide headers that identify the server to prevent information leakage
proxy_hide_header X-Powered-By;
fastcgi_hide_header X-Powered-By;
server_tokens off;
# Enable gzip compression by default
gzip on;
gzip_proxied any;
# Based on CloudFlare's recommended settings
gzip_types text/richtext text/plain text/css text/x-script text/x-component text/x-java-source text/x-markdown application/javascript application/x-javascript text/javascript text/js image/x-icon image/vnd.microsoft.icon application/x-perl application/x-httpd-cgi text/xml application/xml application/rss+xml application/vnd.api+json application/x-protobuf application/json multipart/bag multipart/mixed application/xhtml+xml font/ttf font/otf font/x-woff image/svg+xml application/vnd.ms-fontobject application/ttf application/x-ttf application/otf application/x-otf application/truetype application/opentype application/x-opentype application/font-woff application/eot application/font application/font-sfnt application/wasm application/javascript-binast application/manifest+json application/ld+json application/graphql+json application/geo+json;
gzip_vary on;
gzip_disable "msie6";
# Include server configs
include /etc/nginx/http.d/*.conf;
}

View File

@@ -0,0 +1,103 @@
# /etc/nginx/nginx.conf
user nginx;
# Set number of worker processes automatically based on number of CPU cores.
worker_processes auto;
# Enables the use of JIT for regular expressions to speed-up their processing.
pcre_jit on;
# Configures default error logger.
error_log /var/log/nginx/error.log warn;
# Includes files with directives to load dynamic modules.
include /etc/nginx/modules/*.conf;
# Include files with config snippets into the root context.
include /etc/nginx/conf.d/*.conf;
events {
# The maximum number of simultaneous connections that can be opened by
# a worker process.
worker_connections 1024;
}
http {
# Includes mapping of file name extensions to MIME types of responses
# and defines the default type.
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Name servers used to resolve names of upstream servers into addresses.
# It's also needed when using tcpsocket and udpsocket in Lua modules.
#resolver 1.1.1.1 1.0.0.1 [2606:4700:4700::1111] [2606:4700:4700::1001];
# Don't tell nginx version to the clients. Default is 'on'.
server_tokens off;
# Specifies the maximum accepted body size of a client request, as
# indicated by the request header Content-Length. If the stated content
# length is greater than this size, then the client receives the HTTP
# error code 413. Set to 0 to disable. Default is '1m'.
client_max_body_size 1m;
# Sendfile copies data between one FD and other from within the kernel,
# which is more efficient than read() + write(). Default is off.
sendfile on;
# Causes nginx to attempt to send its HTTP response head in one packet,
# instead of using partial frames. Default is 'off'.
tcp_nopush on;
# Enables the specified protocols. Default is TLSv1 TLSv1.1 TLSv1.2.
# TIP: If you're not obligated to support ancient clients, remove TLSv1.1.
ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
# Path of the file with Diffie-Hellman parameters for EDH ciphers.
# TIP: Generate with: `openssl dhparam -out /etc/ssl/nginx/dh2048.pem 2048`
#ssl_dhparam /etc/ssl/nginx/dh2048.pem;
# Specifies that our cipher suits should be preferred over client ciphers.
# Default is 'off'.
ssl_prefer_server_ciphers on;
# Enables a shared SSL cache with size that can hold around 8000 sessions.
# Default is 'none'.
ssl_session_cache shared:SSL:2m;
# Specifies a time during which a client may reuse the session parameters.
# Default is '5m'.
ssl_session_timeout 1h;
# Disable TLS session tickets (they are insecure). Default is 'on'.
ssl_session_tickets off;
# Enable gzipping of responses.
#gzip on;
# Set the Vary HTTP header as defined in the RFC 2616. Default is 'off'.
gzip_vary on;
# Helper variable for proxying websockets.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# Specifies the main log format.
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
# Sets the path, format, and configuration for a buffered log write.
access_log /var/log/nginx/access.log main;
# Includes virtual hosts configs.
include /etc/nginx/http.d/*.conf;
}

View File

@@ -0,0 +1,65 @@
[supervisord]
nodaemon=true
logfile=/dev/null
logfile_maxbytes=0
pidfile=/app/run/supervisord.pid
[unix_http_server]
file=/app/run/supervisord.sock
username = dummy
password = dummy
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///app/run/supervisord.sock
username = dummy
password = dummy
[program:php-fpm]
command=php-fpm -F
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
autorestart=true
autostart=false
startretries=0
startsecs=3
priority=20
[program:nginx]
command=nginx -c /etc/nginx/nginx.conf -e /app/run/nginx.error.log -g 'daemon off;'
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
autorestart=true
autostart=false
startretries=0
startsecs=3
priority=30
[program:scheduler]
command=php artisan schedule:work
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr
stdout_logfile_maxbytes=0
stderr_logfile_maxbytes=0
autorestart=true
autostart=false
startsecs=3
priority=40
[program:entrypoint]
command=/app/Backend/entrypoint.sh
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr
stdout_logfile_maxbytes=0
stderr_logfile_maxbytes=0
autorestart=false
autostart=true
startsecs=0
startretries=1
priority=10

View File

@@ -0,0 +1,63 @@
; Custom FPM configuration
[global]
; Log to stderr
;error_log = /dev/stderr
error_log = /app/log/fpm.log
[www]
; The address on which to accept FastCGI requests.
; Valid syntaxes are:
; 'ip.add.re.ss:port' - to listen on a TCP socket to a specific IPv4 address on
; a specific port;
; '[ip:6:addr:ess]:port' - to listen on a TCP socket to a specific IPv6 address on
; a specific port;
; 'port' - to listen on a TCP socket to all addresses
; (IPv6 and IPv4-mapped) on a specific port;
; '/path/to/unix/socket' - to listen on a unix socket.
; Note: This value is mandatory.
listen = /app/run/php-fpm.sock
listen.owner = bot
listen.group = bot
access.log = /app/log/$pool.fpm.access.log
; Enable status page
pm.status_path = /fpm-status
; Ondemand process manager
pm = ondemand
; The number of child processes to be created when pm is set to 'static' and the
; maximum number of child processes when pm is set to 'dynamic' or 'ondemand'.
; This value sets the limit on the number of simultaneous requests that will be
; served. Equivalent to the ApacheMaxClients directive with mpm_prefork.
; Equivalent to the PHP_FCGI_CHILDREN environment variable in the original PHP
; CGI. The below defaults are based on a server without much resources. Don't
; forget to tweak pm.* to fit your needs.
; Note: Used when pm is set to 'static', 'dynamic' or 'ondemand'
; Note: This value is mandatory.
pm.max_children = 100
; The number of seconds after which an idle process will be killed.
; Note: Used only when pm is set to 'ondemand'
; Default Value: 10s
pm.process_idle_timeout = 10s;
; The number of requests each child process should execute before respawning.
; This can be useful to work around memory leaks in 3rd party libraries. For
; endless request processing specify '0'. Equivalent to PHP_FCGI_MAX_REQUESTS.
; Default Value: 0
pm.max_requests = 1000
; Make sure the FPM workers can reach the environment variables for configuration
clear_env = no
; Catch output from PHP
catch_workers_output = yes
; Remove the 'child 10 said into stderr' prefix in the log and only show the actual message
decorate_workers_output = no
; Enable ping page to use in healthcheck
ping.path = /fpm-ping

View File

@@ -0,0 +1,490 @@
; Start a new pool named 'www'.
; the variable $pool can be used in any directive and will be replaced by the
; pool name ('www' here)
[www]
; Per pool prefix
; It only applies on the following directives:
; - 'access.log'
; - 'slowlog'
; - 'listen' (unixsocket)
; - 'chroot'
; - 'chdir'
; - 'php_values'
; - 'php_admin_values'
; When not set, the global prefix (or /usr) applies instead.
; Note: This directive can also be relative to the global prefix.
; Default Value: none
;prefix = /path/to/pools/$pool
; Unix user/group of the child processes. This can be used only if the master
; process running user is root. It is set after the child process is created.
; The user and group can be specified either by their name or by their numeric
; IDs.
; Note: If the user is root, the executable needs to be started with
; --allow-to-run-as-root option to work.
; Default Values: The user is set to master process running user by default.
; If the group is not set, the user's group is used.
user = nobody
group = nobody
; The address on which to accept FastCGI requests.
; Valid syntaxes are:
; 'ip.add.re.ss:port' - to listen on a TCP socket to a specific IPv4 address on
; a specific port;
; '[ip:6:addr:ess]:port' - to listen on a TCP socket to a specific IPv6 address on
; a specific port;
; 'port' - to listen on a TCP socket to all addresses
; (IPv6 and IPv4-mapped) on a specific port;
; '/path/to/unix/socket' - to listen on a unix socket.
; Note: This value is mandatory.
listen = 127.0.0.1:9000
; Set listen(2) backlog.
; Default Value: 511 (-1 on Linux, FreeBSD and OpenBSD)
;listen.backlog = 511
; Set permissions for unix socket, if one is used. In Linux, read/write
; permissions must be set in order to allow connections from a web server. Many
; BSD-derived systems allow connections regardless of permissions. The owner
; and group can be specified either by name or by their numeric IDs.
; Default Values: Owner is set to the master process running user. If the group
; is not set, the owner's group is used. Mode is set to 0660.
;listen.owner = nobody
;listen.group = nobody
;listen.mode = 0660
; When POSIX Access Control Lists are supported you can set them using
; these options, value is a comma separated list of user/group names.
; When set, listen.owner and listen.group are ignored
;listen.acl_users =
;listen.acl_groups =
; List of addresses (IPv4/IPv6) of FastCGI clients which are allowed to connect.
; Equivalent to the FCGI_WEB_SERVER_ADDRS environment variable in the original
; PHP FCGI (5.2.2+). Makes sense only with a tcp listening socket. Each address
; must be separated by a comma. If this value is left blank, connections will be
; accepted from any ip address.
; Default Value: any
;listen.allowed_clients = 127.0.0.1
; Set the associated the route table (FIB). FreeBSD only
; Default Value: -1
;listen.setfib = 1
; Specify the nice(2) priority to apply to the pool processes (only if set)
; The value can vary from -19 (highest priority) to 20 (lower priority)
; Note: - It will only work if the FPM master process is launched as root
; - The pool processes will inherit the master process priority
; unless it specified otherwise
; Default Value: no set
; process.priority = -19
; Set the process dumpable flag (PR_SET_DUMPABLE prctl for Linux or
; PROC_TRACE_CTL procctl for FreeBSD) even if the process user
; or group is different than the master process user. It allows to create process
; core dump and ptrace the process for the pool user.
; Default Value: no
; process.dumpable = yes
; Choose how the process manager will control the number of child processes.
; Possible Values:
; static - a fixed number (pm.max_children) of child processes;
; dynamic - the number of child processes are set dynamically based on the
; following directives. With this process management, there will be
; always at least 1 children.
; pm.max_children - the maximum number of children that can
; be alive at the same time.
; pm.start_servers - the number of children created on startup.
; pm.min_spare_servers - the minimum number of children in 'idle'
; state (waiting to process). If the number
; of 'idle' processes is less than this
; number then some children will be created.
; pm.max_spare_servers - the maximum number of children in 'idle'
; state (waiting to process). If the number
; of 'idle' processes is greater than this
; number then some children will be killed.
; pm.max_spawn_rate - the maximum number of rate to spawn child
; processes at once.
; ondemand - no children are created at startup. Children will be forked when
; new requests will connect. The following parameter are used:
; pm.max_children - the maximum number of children that
; can be alive at the same time.
; pm.process_idle_timeout - The number of seconds after which
; an idle process will be killed.
; Note: This value is mandatory.
pm = dynamic
; The number of child processes to be created when pm is set to 'static' and the
; maximum number of child processes when pm is set to 'dynamic' or 'ondemand'.
; This value sets the limit on the number of simultaneous requests that will be
; served. Equivalent to the ApacheMaxClients directive with mpm_prefork.
; Equivalent to the PHP_FCGI_CHILDREN environment variable in the original PHP
; CGI. The below defaults are based on a server without much resources. Don't
; forget to tweak pm.* to fit your needs.
; Note: Used when pm is set to 'static', 'dynamic' or 'ondemand'
; Note: This value is mandatory.
pm.max_children = 5
; The number of child processes created on startup.
; Note: Used only when pm is set to 'dynamic'
; Default Value: (min_spare_servers + max_spare_servers) / 2
pm.start_servers = 2
; The desired minimum number of idle server processes.
; Note: Used only when pm is set to 'dynamic'
; Note: Mandatory when pm is set to 'dynamic'
pm.min_spare_servers = 1
; The desired maximum number of idle server processes.
; Note: Used only when pm is set to 'dynamic'
; Note: Mandatory when pm is set to 'dynamic'
pm.max_spare_servers = 3
; The number of rate to spawn child processes at once.
; Note: Used only when pm is set to 'dynamic'
; Note: Mandatory when pm is set to 'dynamic'
; Default Value: 32
;pm.max_spawn_rate = 32
; The number of seconds after which an idle process will be killed.
; Note: Used only when pm is set to 'ondemand'
; Default Value: 10s
;pm.process_idle_timeout = 10s;
; The number of requests each child process should execute before respawning.
; This can be useful to work around memory leaks in 3rd party libraries. For
; endless request processing specify '0'. Equivalent to PHP_FCGI_MAX_REQUESTS.
; Default Value: 0
;pm.max_requests = 500
; The URI to view the FPM status page. If this value is not set, no URI will be
; recognized as a status page. It shows the following information:
; pool - the name of the pool;
; process manager - static, dynamic or ondemand;
; start time - the date and time FPM has started;
; start since - number of seconds since FPM has started;
; accepted conn - the number of request accepted by the pool;
; listen queue - the number of request in the queue of pending
; connections (see backlog in listen(2));
; max listen queue - the maximum number of requests in the queue
; of pending connections since FPM has started;
; listen queue len - the size of the socket queue of pending connections;
; idle processes - the number of idle processes;
; active processes - the number of active processes;
; total processes - the number of idle + active processes;
; max active processes - the maximum number of active processes since FPM
; has started;
; max children reached - number of times, the process limit has been reached,
; when pm tries to start more children (works only for
; pm 'dynamic' and 'ondemand');
; Value are updated in real time.
; Example output:
; pool: www
; process manager: static
; start time: 01/Jul/2011:17:53:49 +0200
; start since: 62636
; accepted conn: 190460
; listen queue: 0
; max listen queue: 1
; listen queue len: 42
; idle processes: 4
; active processes: 11
; total processes: 15
; max active processes: 12
; max children reached: 0
;
; By default the status page output is formatted as text/plain. Passing either
; 'html', 'xml' or 'json' in the query string will return the corresponding
; output syntax. Example:
; http://www.foo.bar/status
; http://www.foo.bar/status?json
; http://www.foo.bar/status?html
; http://www.foo.bar/status?xml
;
; By default the status page only outputs short status. Passing 'full' in the
; query string will also return status for each pool process.
; Example:
; http://www.foo.bar/status?full
; http://www.foo.bar/status?json&full
; http://www.foo.bar/status?html&full
; http://www.foo.bar/status?xml&full
; The Full status returns for each process:
; pid - the PID of the process;
; state - the state of the process (Idle, Running, ...);
; start time - the date and time the process has started;
; start since - the number of seconds since the process has started;
; requests - the number of requests the process has served;
; request duration - the duration in µs of the requests;
; request method - the request method (GET, POST, ...);
; request URI - the request URI with the query string;
; content length - the content length of the request (only with POST);
; user - the user (PHP_AUTH_USER) (or '-' if not set);
; script - the main script called (or '-' if not set);
; last request cpu - the %cpu the last request consumed
; it's always 0 if the process is not in Idle state
; because CPU calculation is done when the request
; processing has terminated;
; last request memory - the max amount of memory the last request consumed
; it's always 0 if the process is not in Idle state
; because memory calculation is done when the request
; processing has terminated;
; If the process is in Idle state, then information is related to the
; last request the process has served. Otherwise information is related to
; the current request being served.
; Example output:
; ************************
; pid: 31330
; state: Running
; start time: 01/Jul/2011:17:53:49 +0200
; start since: 63087
; requests: 12808
; request duration: 1250261
; request method: GET
; request URI: /test_mem.php?N=10000
; content length: 0
; user: -
; script: /home/fat/web/docs/php/test_mem.php
; last request cpu: 0.00
; last request memory: 0
;
; Note: There is a real-time FPM status monitoring sample web page available
; It's available in: /usr/share/php84/fpm/status.html
;
; Note: The value must start with a leading slash (/). The value can be
; anything, but it may not be a good idea to use the .php extension or it
; may conflict with a real PHP file.
; Default Value: not set
;pm.status_path = /status
; The address on which to accept FastCGI status request. This creates a new
; invisible pool that can handle requests independently. This is useful
; if the main pool is busy with long running requests because it is still possible
; to get the status before finishing the long running requests.
;
; Valid syntaxes are:
; 'ip.add.re.ss:port' - to listen on a TCP socket to a specific IPv4 address on
; a specific port;
; '[ip:6:addr:ess]:port' - to listen on a TCP socket to a specific IPv6 address on
; a specific port;
; 'port' - to listen on a TCP socket to all addresses
; (IPv6 and IPv4-mapped) on a specific port;
; '/path/to/unix/socket' - to listen on a unix socket.
; Default Value: value of the listen option
;pm.status_listen = 127.0.0.1:9001
; The ping URI to call the monitoring page of FPM. If this value is not set, no
; URI will be recognized as a ping page. This could be used to test from outside
; that FPM is alive and responding, or to
; - create a graph of FPM availability (rrd or such);
; - remove a server from a group if it is not responding (load balancing);
; - trigger alerts for the operating team (24/7).
; Note: The value must start with a leading slash (/). The value can be
; anything, but it may not be a good idea to use the .php extension or it
; may conflict with a real PHP file.
; Default Value: not set
;ping.path = /ping
; This directive may be used to customize the response of a ping request. The
; response is formatted as text/plain with a 200 response code.
; Default Value: pong
;ping.response = pong
; The access log file
; Default: not set
;access.log = log/php84/$pool.access.log
; The access log format.
; The following syntax is allowed
; %%: the '%' character
; %C: %CPU used by the request
; it can accept the following format:
; - %{user}C for user CPU only
; - %{system}C for system CPU only
; - %{total}C for user + system CPU (default)
; %d: time taken to serve the request
; it can accept the following format:
; - %{seconds}d (default)
; - %{milliseconds}d
; - %{milli}d
; - %{microseconds}d
; - %{micro}d
; %e: an environment variable (same as $_ENV or $_SERVER)
; it must be associated with embraces to specify the name of the env
; variable. Some examples:
; - server specifics like: %{REQUEST_METHOD}e or %{SERVER_PROTOCOL}e
; - HTTP headers like: %{HTTP_HOST}e or %{HTTP_USER_AGENT}e
; %f: script filename
; %l: content-length of the request (for POST request only)
; %m: request method
; %M: peak of memory allocated by PHP
; it can accept the following format:
; - %{bytes}M (default)
; - %{kilobytes}M
; - %{kilo}M
; - %{megabytes}M
; - %{mega}M
; %n: pool name
; %o: output header
; it must be associated with embraces to specify the name of the header:
; - %{Content-Type}o
; - %{X-Powered-By}o
; - %{Transfert-Encoding}o
; - ....
; %p: PID of the child that serviced the request
; %P: PID of the parent of the child that serviced the request
; %q: the query string
; %Q: the '?' character if query string exists
; %r: the request URI (without the query string, see %q and %Q)
; %R: remote IP address
; %s: status (response code)
; %t: server time the request was received
; it can accept a strftime(3) format:
; %d/%b/%Y:%H:%M:%S %z (default)
; The strftime(3) format must be encapsulated in a %{<strftime_format>}t tag
; e.g. for a ISO8601 formatted timestring, use: %{%Y-%m-%dT%H:%M:%S%z}t
; %T: time the log has been written (the request has finished)
; it can accept a strftime(3) format:
; %d/%b/%Y:%H:%M:%S %z (default)
; The strftime(3) format must be encapsulated in a %{<strftime_format>}t tag
; e.g. for a ISO8601 formatted timestring, use: %{%Y-%m-%dT%H:%M:%S%z}t
; %u: basic auth user if specified in Authorization header
;
; Default: "%R - %u %t \"%m %r\" %s"
;access.format = "%R - %u %t \"%m %r%Q%q\" %s %f %{milli}d %{kilo}M %C%%"
; A list of request_uri values which should be filtered from the access log.
;
; As a security precaution, this setting will be ignored if:
; - the request method is not GET or HEAD; or
; - there is a request body; or
; - there are query parameters; or
; - the response code is outwith the successful range of 200 to 299
;
; Note: The paths are matched against the output of the access.format tag "%r".
; On common configurations, this may look more like SCRIPT_NAME than the
; expected pre-rewrite URI.
;
; Default Value: not set
;access.suppress_path[] = /ping
;access.suppress_path[] = /health_check.php
; The log file for slow requests
; Default Value: not set
; Note: slowlog is mandatory if request_slowlog_timeout is set
;slowlog = log/php84/$pool.slow.log
; The timeout for serving a single request after which a PHP backtrace will be
; dumped to the 'slowlog' file. A value of '0s' means 'off'.
; Available units: s(econds)(default), m(inutes), h(ours), or d(ays)
; Default Value: 0
;request_slowlog_timeout = 0
; Depth of slow log stack trace.
; Default Value: 20
;request_slowlog_trace_depth = 20
; The timeout for serving a single request after which the worker process will
; be killed. This option should be used when the 'max_execution_time' ini option
; does not stop script execution for some reason. A value of '0' means 'off'.
; Available units: s(econds)(default), m(inutes), h(ours), or d(ays)
; Default Value: 0
;request_terminate_timeout = 0
; The timeout set by 'request_terminate_timeout' ini option is not engaged after
; application calls 'fastcgi_finish_request' or when application has finished and
; shutdown functions are being called (registered via register_shutdown_function).
; This option will enable timeout limit to be applied unconditionally
; even in such cases.
; Default Value: no
;request_terminate_timeout_track_finished = no
; Set open file descriptor rlimit.
; Default Value: system defined value
;rlimit_files = 1024
; Set max core size rlimit.
; Possible Values: 'unlimited' or an integer greater or equal to 0
; Default Value: system defined value
;rlimit_core = 0
; Chroot to this directory at the start. This value must be defined as an
; absolute path. When this value is not set, chroot is not used.
; Note: you can prefix with '$prefix' to chroot to the pool prefix or one
; of its subdirectories. If the pool prefix is not set, the global prefix
; will be used instead.
; Note: chrooting is a great security feature and should be used whenever
; possible. However, all PHP paths will be relative to the chroot
; (error_log, sessions.save_path, ...).
; Default Value: not set
;chroot =
; Chdir to this directory at the start.
; Note: relative path can be used.
; Default Value: current directory or / when chroot
;chdir = /var/www
; Redirect worker stdout and stderr into main error log. If not set, stdout and
; stderr will be redirected to /dev/null according to FastCGI specs.
; Note: on highloaded environment, this can cause some delay in the page
; process time (several ms).
; Default Value: no
;catch_workers_output = yes
; Decorate worker output with prefix and suffix containing information about
; the child that writes to the log and if stdout or stderr is used as well as
; log level and time. This options is used only if catch_workers_output is yes.
; Settings to "no" will output data as written to the stdout or stderr.
; Default value: yes
;decorate_workers_output = no
; Clear environment in FPM workers
; Prevents arbitrary environment variables from reaching FPM worker processes
; by clearing the environment in workers before env vars specified in this
; pool configuration are added.
; Setting to "no" will make all environment variables available to PHP code
; via getenv(), $_ENV and $_SERVER.
; Default Value: yes
;clear_env = no
; Limits the extensions of the main script FPM will allow to parse. This can
; prevent configuration mistakes on the web server side. You should only limit
; FPM to .php extensions to prevent malicious users to use other extensions to
; execute php code.
; Note: set an empty value to allow all extensions.
; Default Value: .php
;security.limit_extensions = .php .php3 .php4 .php5 .php7
; Pass environment variables like LD_LIBRARY_PATH. All $VARIABLEs are taken from
; the current environment.
; Default Value: clean env
;env[HOSTNAME] = $HOSTNAME
;env[PATH] = /usr/local/bin:/usr/bin:/bin
;env[TMP] = /tmp
;env[TMPDIR] = /tmp
;env[TEMP] = /tmp
; Additional php.ini defines, specific to this pool of workers. These settings
; overwrite the values previously defined in the php.ini. The directives are the
; same as the PHP SAPI:
; php_value/php_flag - you can set classic ini defines which can
; be overwritten from PHP call 'ini_set'.
; php_admin_value/php_admin_flag - these directives won't be overwritten by
; PHP call 'ini_set'
; For php_*flag, valid values are on, off, 1, 0, true, false, yes or no.
; Defining 'extension' will load the corresponding shared extension from
; extension_dir. Defining 'disable_functions' or 'disable_classes' will not
; overwrite previously defined php.ini values, but will append the new value
; instead.
; Note: path INI options can be relative and will be expanded with the prefix
; (pool, global or /usr)
; Default Value: nothing is defined by default except the values in php.ini and
; specified at startup with the -d argument
;php_admin_value[sendmail_path] = /usr/sbin/sendmail -t -i -f www@my.domain.com
;php_flag[display_errors] = off
;php_admin_value[error_log] = /var/log/php84/$pool.error.log
;php_admin_flag[log_errors] = on
;php_admin_value[memory_limit] = 32M

203
build/nginx/entrypoint.sh Executable file
View File

@@ -0,0 +1,203 @@
#!/bin/bash
# constants
dataBaseFile=database/sqlite/database.sqlite
# ---------------------------------------------------------------------------
# functions
# Creating storage folders
function create_storage_folders {
# create storage folders for laravel
mkdir -p ./storage/framework/{cache,sessions,testing,views} || {
# fail if something went wrong
php artisan cli:error "Making storage folders failed!"
exit 1
}
# create log folder
mkdir -p ./storage/logs || {
# fail if something went wrong
php artisan cli:error "Making log folder failed!"
exit 1
}
}
# Initalizing caches
function initalize_cache {
# populating/updating caches
php artisan optimize \
&& php artisan filament:cache-components || {
# fail if something went wrong
php artisan cli:error "Clearing cache failed!"
exit 5
}
}
# initialize environment file
function initalize_environment_file() {
# if the .env doesn't exists, create it
[[ ! -f .env ]] && {
# fail if environment cofig doesn't exists
[[ -f .env.config ]] || {
php artisan cli:error "Please provide a \".env.config\" file!"
exit 7
}
# fail if environment cofig is not readable
[[ -r .env.config ]] || {
php artisan cli:error "Cannot read \".env.config\" file! Please check permissions!"
exit 7
}
# create the custom environment file
cp .env.config .env
return
}
# if .env already exists and no config file is found, nothing to do
[[ ! -f .env.config ]] && {
return
}
# if the config file has ben updated since creating the .env file
[[ ".env.config" -nt ".env" ]] && {
# backup the current configuration
cp .env .env.old
# fail if environment cofig isn't readable
[[ -r .env.config ]] || {
php artisan cli:error "Cannot read \".env.config\" file! Please check permissions!"
exit 7
}
# create our custom environment file
cp .env.config .env
}
}
# create and save a new encryption key if it does not exists
function generate_application_key {
php artisan state:initialized --silent || {
php artisan cli:info "Running for the forst time, generating application key"
local key=$(dd if=/dev/urandom bs=32 count=1 2>/dev/null | base64) \
&& sed s~APP_KEY=.*\$~APP_KEY\=base64:$key~ .env > /tmp/.env \
&& cat /tmp/.env > .env || {
# fail if something went wrong
php artisan cli:error "Generating the encription key failed!"
exit 2
}
}
}
# run database migration
function migrate_database {
# fail if the database file is not writeable
[[ ! -w $dataBaseFile ]] && {
php artisan cli:error "Cannot write databese! Please check permissions!"
exit 3
}
# create a backup - NOTE: this may not be a good practice...
cp $dataBaseFile $dataBaseFile.bak
php artisan migrate --force || {
# fail if something went wrong
php artisan cli:error "Migration failed! See storage/logs/laravel.log for more info."
exit 3
}
}
# run databesa seeding
function seed_database {
#NOTE: this only creates the admin user if it does not exists yet
php artisan db:seed --force || {
# fail if something went wrong
php artisan cli:error "Seeding failed! See storage/logs/laravel.log for more info."
exit 4
}
}
# start a supervisord process
# @internal
# @param string $1 command name example: "bash"
# @param string $2 command display name example: "Bourne Again Shell"
function start_process {
# variables
local process=$1
local process_name=$2
# log message
php artisan cli:info "Starting ${process_name} process..."
# start the process
supervisorctl start ${process} || {
# log and fail if the process creation failed
php artisan cli:error "Could not start ${process_name} process, exiting :("
supervisorctl shutdown
exit 99 # this should never be called
}
}
# start supervisord defined server processes
function start_server_processes {
# start php
start_process "php-fpm" "PHP-Fpm"
# start nginx
start_process "nginx" "Nginx"
# start schedule:work
start_process "scheduler" "Scheduler"
}
# ---- main -----------------------------------------------------------------
# Initalizing the environment file
initalize_environment_file
# Creating storage folders
create_storage_folders
# generating application encryption key
generate_application_key
# migrating the database
migrate_database
# seed the database (if not already seeded)
seed_database
# clear cache on (re)start
initalize_cache
# adding notice to the app log
echo -e "$(date +'[%Y-%m-%d %H:%M:%S]') production.NOTICE: Entrypoint reached.\n" >> storage/logs/laravel.log
# starting server processes
start_server_processes
# confirm successfull program startup
php artisan cli:info "Program stared succesfully."
exit 0

141
readme.md Normal file
View File

@@ -0,0 +1,141 @@
![Docker build](https://proxima.goliath.hu/proxima/backend/actions/workflows/latest.yaml/badge.svg)
![Docker build](https://proxima.goliath.hu/proxima/backend/actions/workflows/testing.yaml/badge.svg?branch=dev)
![Proxima Discord bot](res/logo.svg)
# Proxima Backend
[Proxima](https://proxima.goliath.hu) is a **Remainder Application**, like many others, which primarily uses [Discord](https://discord.com/) to deliver the remainders to the user.
It provides an API interface to be embedded in other services, using Bearer Token Authentication.
This is the source code for the backend application.
This backend handles all the data managment and provides the API interface for the clients to be used.
This backend application is intended to be run as a docker container, see below.
For more details, see: [Proxima -> Backend](https://proxima.goliath.hu#backend)
# Main Technologies used
- [PHP 8.4+](https://www.php.net/)
- [composer](https://getcomposer.org/)
- [Laravel 11+ ](https://laravel.com/)
- [Sanctum](https://laravel.com/docs/11.x/sanctum)
- [Livevire](https://laravel-livewire.com/)
- [Filament 3+](https://filamentphp.com/)
- [Scribe](https://scribe.knuckles.wtf/laravel)
- and many more...
# What is needed to run the backend
- A working docker instance with compose.
# Runninig with docker
### Follow these easy steps to setup and run the backend application:
> **NOTE** The program inside the container is running under a **non-privileged** user with
>
> `UID:1000` and `GID:1000`.
>
> The database file/folder must be writable for that user!
>
> In the example we just make it writable to everyone (please use proper access control instead!)
>
> *Or jou can build your own image with different `UID`/`GID` values.*
#### 1. Create the directory structure (bash):
```bash
mkdir -p volumes/backend/{database,env}
```
#### 2. Update directory permissions to allow write access to the restricted docker user
```bash
chmod o+w volumes/backend/database
```
#### 3. (OPTIONAL) If you already have a working database file, you can copy it to the app:
```bash
cp PATH/TO/DATABASE/<database.sqlite> volumes/backend/database/
chmod o+w volumes/backend/database/database.sqlite
```
#### 4. Create the `.env.config` file from [src/.env.example](src/.env.example) file.
```bash
wget -O volumes/backend/env/.env.config https://proxima.goliath.hu/proxima/backend/raw/branch/main/src/.env.example
-OR-
curl -f -o volumes/backend/env/.env.config https://proxima.goliath.hu/proxima/backend/raw/branch/main/src/.env.example
```
#### 5. Customize the `.env.config` file.
> **NOTE**: The program creates its own `.env` file based on the `.env.config` file.<br>
> If you modify your `.env.config` file, simply restart the container and the new settings will take effect immediately.
```bash
nano volumes/backend/env/.env.config
```
1. Replace or fill in the following values (after the "=" sign)
1. **APP_NAME** - The application's name <br>
*defualt:* "`Backend`"
1. **APP_TIMEZONE** - The application's timezone<br>
*default:* "`UTC`"
1. **APP_URL** - The application's url<br>
*default:* "`localhost:9000`"
1. **ADMIN_EMAIL** - The admin email for the backend<br>
*default:* "`admin@example.com`"
1. **ADMIN_PASSWORD** - The admin password for the backend<br>
*default:* "`PlEaSe_ChAnGe_Me`"
#### 6.1 Run with docker
```bash
docker run \
-it \
--rm \
--name "proxima-backend" \
-v "./volumes/backend/env/.env.config:/app/Backend/.env.config" \
-v "./volumes/backend/database/:/app/Backend/database/sqlite/" \
-p "9000:9000" \
git.magrathea.hu/proxima/backend:latest
```
#### 6.2 Running with docker compose
#### 6.2.1 Create docker compose file `docker-compose.yaml`:
```yaml
---
services:
backend:
image: git.magrathea.hu/proxima/backend:latest
container_name: backend
ports:
- "9000:9000"
volumes:
- "./volumes/backend/env/.env.config:/app/Backend/.env.config"
- "./volumes/backend/database/:/app/Backend/database/sqlite/"
restart: unless-stopped
```
#### 6.2.2 Start up service
```bash
docker compose up -d
```
#### 6.3 You can watch the logs of the application
```bash
docker compose logs -f
```

706
res/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 29 KiB

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',
];
}
}

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