Compare commits
7 Commits
main
...
beads-sync
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9fa1546f7 | ||
|
|
7c6eee187c | ||
|
|
fbd46b8e5c | ||
|
|
886b336a08 | ||
|
|
02237735ec | ||
|
|
5e420a0dd8 | ||
|
|
2a55ae934f |
6
.beads/.gitignore
vendored
6
.beads/.gitignore
vendored
@@ -11,12 +11,6 @@ daemon.log
|
|||||||
daemon.pid
|
daemon.pid
|
||||||
bd.sock
|
bd.sock
|
||||||
sync-state.json
|
sync-state.json
|
||||||
.sync.lock
|
|
||||||
last-touched
|
|
||||||
sync_base.jsonl
|
|
||||||
.sync.lock
|
|
||||||
last-touched
|
|
||||||
sync_base.jsonl
|
|
||||||
|
|
||||||
# Local version tracking (prevents upgrade notification spam after git ops)
|
# Local version tracking (prevents upgrade notification spam after git ops)
|
||||||
.local_version
|
.local_version
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
# This setting persists across clones (unlike database config which is gitignored).
|
# This setting persists across clones (unlike database config which is gitignored).
|
||||||
# Can also use BEADS_SYNC_BRANCH env var for local override.
|
# Can also use BEADS_SYNC_BRANCH env var for local override.
|
||||||
# If not set, bd sync will require you to run 'bd config set sync.branch <branch>'.
|
# If not set, bd sync will require you to run 'bd config set sync.branch <branch>'.
|
||||||
sync-branch: "beads-sync"
|
# sync-branch: "beads-sync"
|
||||||
|
|
||||||
# Multi-repo configuration (experimental - bd-307)
|
# Multi-repo configuration (experimental - bd-307)
|
||||||
# Allows hydrating from multiple repositories and routing writes to the correct JSONL
|
# Allows hydrating from multiple repositories and routing writes to the correct JSONL
|
||||||
@@ -59,4 +59,4 @@ sync-branch: "beads-sync"
|
|||||||
# - linear.url
|
# - linear.url
|
||||||
# - linear.api-key
|
# - linear.api-key
|
||||||
# - github.org
|
# - github.org
|
||||||
# - github.repo
|
# - github.repo
|
||||||
|
|||||||
1
.beads/last-touched
Normal file
1
.beads/last-touched
Normal file
@@ -0,0 +1 @@
|
|||||||
|
fotospiel-app-29r
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"database": "beads.db",
|
"database": "beads.db",
|
||||||
"jsonl_export": "issues.jsonl",
|
"jsonl_export": "issues.jsonl"
|
||||||
"last_bd_version": "0.49.0"
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -97,11 +97,6 @@ GOOGLE_CLIENT_ID=
|
|||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
GOOGLE_REDIRECT_URI=${APP_URL}/checkout/auth/google/callback
|
GOOGLE_REDIRECT_URI=${APP_URL}/checkout/auth/google/callback
|
||||||
|
|
||||||
# Facebook OAuth (Checkout comfort login)
|
|
||||||
FACEBOOK_CLIENT_ID=
|
|
||||||
FACEBOOK_CLIENT_SECRET=
|
|
||||||
FACEBOOK_REDIRECT_URI=${APP_URL}/checkout/auth/facebook/callback
|
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
VITE_ENABLE_TENANT_SWITCHER=false
|
VITE_ENABLE_TENANT_SWITCHER=false
|
||||||
REVENUECAT_WEBHOOK_SECRET=
|
REVENUECAT_WEBHOOK_SECRET=
|
||||||
@@ -192,9 +187,5 @@ STORAGE_QUEUE_PENDING_EVENT_MINUTES=8
|
|||||||
STORAGE_QUEUE_FAILED_EVENT_THRESHOLD=2
|
STORAGE_QUEUE_FAILED_EVENT_THRESHOLD=2
|
||||||
STORAGE_QUEUE_FAILED_EVENT_MINUTES=30
|
STORAGE_QUEUE_FAILED_EVENT_MINUTES=30
|
||||||
STORAGE_QUEUE_GUEST_ALERT_TTL=30
|
STORAGE_QUEUE_GUEST_ALERT_TTL=30
|
||||||
STORAGE_CHECKSUM_VALIDATION=true
|
|
||||||
STORAGE_CHECKSUM_ALERT_WINDOW_MINUTES=60
|
|
||||||
STORAGE_CHECKSUM_WARNING=1
|
|
||||||
STORAGE_CHECKSUM_CRITICAL=5
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,7 +12,6 @@ fotospiel-tenant-app
|
|||||||
/resources/js/wayfinder
|
/resources/js/wayfinder
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
/storage/pail
|
/storage/pail
|
||||||
/C:\\wwwroot\\fotospiel-app\\storage\\app/
|
|
||||||
/vendor
|
/vendor
|
||||||
/clients/photobooth-uploader/**/bin
|
/clients/photobooth-uploader/**/bin
|
||||||
/clients/photobooth-uploader/**/obj
|
/clients/photobooth-uploader/**/obj
|
||||||
|
|||||||
@@ -337,8 +337,8 @@ Tokens are design system values that can be referenced using the `$` prefix.
|
|||||||
|
|
||||||
### Color Tokens
|
### Color Tokens
|
||||||
|
|
||||||
- `accent`: #3D5AFE
|
- `accent`: #FFB6C1
|
||||||
- `accentSoft`: #E8ECFF
|
- `accentSoft`: #FFE5EC
|
||||||
- `blue10Dark`: hsl(209, 100%, 60.6%)
|
- `blue10Dark`: hsl(209, 100%, 60.6%)
|
||||||
- `blue10Light`: hsl(208, 100%, 47.3%)
|
- `blue10Light`: hsl(208, 100%, 47.3%)
|
||||||
- `blue11Dark`: hsl(210, 100%, 66.1%)
|
- `blue11Dark`: hsl(210, 100%, 66.1%)
|
||||||
@@ -363,8 +363,8 @@ Tokens are design system values that can be referenced using the `$` prefix.
|
|||||||
- `blue8Light`: hsl(206, 81.9%, 65.3%)
|
- `blue8Light`: hsl(206, 81.9%, 65.3%)
|
||||||
- `blue9Dark`: hsl(206, 100%, 50.0%)
|
- `blue9Dark`: hsl(206, 100%, 50.0%)
|
||||||
- `blue9Light`: hsl(206, 100%, 50.0%)
|
- `blue9Light`: hsl(206, 100%, 50.0%)
|
||||||
- `border`: #F3D6C9
|
- `border`: #F2E4DA
|
||||||
- `danger`: #EF4444
|
- `danger`: #E04848
|
||||||
- `gray10Dark`: hsl(0, 0%, 49.4%)
|
- `gray10Dark`: hsl(0, 0%, 49.4%)
|
||||||
- `gray10Light`: hsl(0, 0%, 52.3%)
|
- `gray10Light`: hsl(0, 0%, 52.3%)
|
||||||
- `gray11Dark`: hsl(0, 0%, 62.8%)
|
- `gray11Dark`: hsl(0, 0%, 62.8%)
|
||||||
@@ -413,7 +413,7 @@ Tokens are design system values that can be referenced using the `$` prefix.
|
|||||||
- `green8Light`: hsl(151, 40.2%, 54.1%)
|
- `green8Light`: hsl(151, 40.2%, 54.1%)
|
||||||
- `green9Dark`: hsl(151, 55.0%, 41.5%)
|
- `green9Dark`: hsl(151, 55.0%, 41.5%)
|
||||||
- `green9Light`: hsl(151, 55.0%, 41.5%)
|
- `green9Light`: hsl(151, 55.0%, 41.5%)
|
||||||
- `muted`: #FFF6F0
|
- `muted`: #F4ECE8
|
||||||
- `orange10Dark`: hsl(24, 100%, 58.5%)
|
- `orange10Dark`: hsl(24, 100%, 58.5%)
|
||||||
- `orange10Light`: hsl(24, 100%, 46.5%)
|
- `orange10Light`: hsl(24, 100%, 46.5%)
|
||||||
- `orange11Dark`: hsl(24, 100%, 62.2%)
|
- `orange11Dark`: hsl(24, 100%, 62.2%)
|
||||||
@@ -462,7 +462,7 @@ Tokens are design system values that can be referenced using the `$` prefix.
|
|||||||
- `pink8Light`: hsl(323, 60.3%, 72.4%)
|
- `pink8Light`: hsl(323, 60.3%, 72.4%)
|
||||||
- `pink9Dark`: hsl(322, 65.0%, 54.5%)
|
- `pink9Dark`: hsl(322, 65.0%, 54.5%)
|
||||||
- `pink9Light`: hsl(322, 65.0%, 54.5%)
|
- `pink9Light`: hsl(322, 65.0%, 54.5%)
|
||||||
- `primary`: #FF5C5C
|
- `primary`: #FF5A5F
|
||||||
- `purple10Dark`: hsl(273, 57.3%, 59.1%)
|
- `purple10Dark`: hsl(273, 57.3%, 59.1%)
|
||||||
- `purple10Light`: hsl(272, 46.8%, 50.3%)
|
- `purple10Light`: hsl(272, 46.8%, 50.3%)
|
||||||
- `purple11Dark`: hsl(275, 80.0%, 71.0%)
|
- `purple11Dark`: hsl(275, 80.0%, 71.0%)
|
||||||
@@ -511,10 +511,10 @@ Tokens are design system values that can be referenced using the `$` prefix.
|
|||||||
- `red8Light`: hsl(359, 69.5%, 74.3%)
|
- `red8Light`: hsl(359, 69.5%, 74.3%)
|
||||||
- `red9Dark`: hsl(358, 75.0%, 59.0%)
|
- `red9Dark`: hsl(358, 75.0%, 59.0%)
|
||||||
- `red9Light`: hsl(358, 75.0%, 59.0%)
|
- `red9Light`: hsl(358, 75.0%, 59.0%)
|
||||||
- `success`: #22C55E
|
- `success`: #06D6A0
|
||||||
- `surface`: #ffffff
|
- `surface`: #ffffff
|
||||||
- `text`: #0B132B
|
- `text`: #1F2937
|
||||||
- `warning`: #FBBF24
|
- `warning`: #F5C542
|
||||||
- `yellow10Dark`: hsl(54, 100%, 68.0%)
|
- `yellow10Dark`: hsl(54, 100%, 68.0%)
|
||||||
- `yellow10Light`: hsl(50, 100%, 48.5%)
|
- `yellow10Light`: hsl(50, 100%, 48.5%)
|
||||||
- `yellow11Dark`: hsl(48, 100%, 47.0%)
|
- `yellow11Dark`: hsl(48, 100%, 47.0%)
|
||||||
|
|||||||
@@ -5152,7 +5152,7 @@ var require_useMergeRefs = __commonJS({
|
|||||||
}
|
}
|
||||||
return React83.useMemo(
|
return React83.useMemo(
|
||||||
() => (0, _mergeRefs.default)(...args),
|
() => (0, _mergeRefs.default)(...args),
|
||||||
|
// eslint-disable-next-line
|
||||||
[...args]
|
[...args]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -12243,7 +12243,7 @@ var require_useMergeRefs2 = __commonJS({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
[...refs]
|
[...refs]
|
||||||
|
// eslint-disable-line react-hooks/exhaustive-deps
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
__name(useMergeRefs, "useMergeRefs");
|
__name(useMergeRefs, "useMergeRefs");
|
||||||
@@ -12938,7 +12938,7 @@ var require_VirtualizedSectionList = __commonJS({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
this._renderItem = (listItemCount) => (
|
this._renderItem = (listItemCount) => (
|
||||||
|
// eslint-disable-next-line react/no-unstable-nested-components
|
||||||
(_ref2) => {
|
(_ref2) => {
|
||||||
var item = _ref2.item, index5 = _ref2.index;
|
var item = _ref2.item, index5 = _ref2.index;
|
||||||
var info = this._subExtractor(index5);
|
var info = this._subExtractor(index5);
|
||||||
@@ -30935,17 +30935,17 @@ function useInteractions(propsList) {
|
|||||||
const itemDeps = propsList.map((key) => key == null ? void 0 : key.item);
|
const itemDeps = propsList.map((key) => key == null ? void 0 : key.item);
|
||||||
const getReferenceProps = React51.useCallback(
|
const getReferenceProps = React51.useCallback(
|
||||||
(userProps) => mergeProps(userProps, propsList, "reference"),
|
(userProps) => mergeProps(userProps, propsList, "reference"),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
referenceDeps
|
referenceDeps
|
||||||
);
|
);
|
||||||
const getFloatingProps = React51.useCallback(
|
const getFloatingProps = React51.useCallback(
|
||||||
(userProps) => mergeProps(userProps, propsList, "floating"),
|
(userProps) => mergeProps(userProps, propsList, "floating"),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
floatingDeps
|
floatingDeps
|
||||||
);
|
);
|
||||||
const getItemProps = React51.useCallback(
|
const getItemProps = React51.useCallback(
|
||||||
(userProps) => mergeProps(userProps, propsList, "item"),
|
(userProps) => mergeProps(userProps, propsList, "item"),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
itemDeps
|
itemDeps
|
||||||
);
|
);
|
||||||
return React51.useMemo(() => ({
|
return React51.useMemo(() => ({
|
||||||
@@ -33866,7 +33866,7 @@ function FloatingFocusManager(props) {
|
|||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
const tabbableReturnElement = getFirstTabbableElement(returnElement);
|
const tabbableReturnElement = getFirstTabbableElement(returnElement);
|
||||||
if (
|
if (
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
returnFocusRef.current && !preventReturnFocusRef.current && isHTMLElement(tabbableReturnElement) && // If the focus moved somewhere else after mount, avoid returning focus
|
returnFocusRef.current && !preventReturnFocusRef.current && isHTMLElement(tabbableReturnElement) && // If the focus moved somewhere else after mount, avoid returning focus
|
||||||
// since it likely entered a different element which should be
|
// since it likely entered a different element which should be
|
||||||
// respected: https://github.com/floating-ui/floating-ui/issues/2607
|
// respected: https://github.com/floating-ui/floating-ui/issues/2607
|
||||||
@@ -34615,17 +34615,17 @@ function useInteractions2(propsList) {
|
|||||||
const itemDeps = propsList.map((key) => key == null ? void 0 : key.item);
|
const itemDeps = propsList.map((key) => key == null ? void 0 : key.item);
|
||||||
const getReferenceProps = React60.useCallback(
|
const getReferenceProps = React60.useCallback(
|
||||||
(userProps) => mergeProps2(userProps, propsList, "reference"),
|
(userProps) => mergeProps2(userProps, propsList, "reference"),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
referenceDeps
|
referenceDeps
|
||||||
);
|
);
|
||||||
const getFloatingProps = React60.useCallback(
|
const getFloatingProps = React60.useCallback(
|
||||||
(userProps) => mergeProps2(userProps, propsList, "floating"),
|
(userProps) => mergeProps2(userProps, propsList, "floating"),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
floatingDeps
|
floatingDeps
|
||||||
);
|
);
|
||||||
const getItemProps = React60.useCallback(
|
const getItemProps = React60.useCallback(
|
||||||
(userProps) => mergeProps2(userProps, propsList, "item"),
|
(userProps) => mergeProps2(userProps, propsList, "item"),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
itemDeps
|
itemDeps
|
||||||
);
|
);
|
||||||
return React60.useMemo(() => ({
|
return React60.useMemo(() => ({
|
||||||
@@ -39482,17 +39482,17 @@ function useInteractions3(propsList) {
|
|||||||
const itemDeps = propsList.map((key) => key == null ? void 0 : key.item);
|
const itemDeps = propsList.map((key) => key == null ? void 0 : key.item);
|
||||||
const getReferenceProps = React76.useCallback(
|
const getReferenceProps = React76.useCallback(
|
||||||
(userProps) => mergeProps3(userProps, propsList, "reference"),
|
(userProps) => mergeProps3(userProps, propsList, "reference"),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
referenceDeps
|
referenceDeps
|
||||||
);
|
);
|
||||||
const getFloatingProps = React76.useCallback(
|
const getFloatingProps = React76.useCallback(
|
||||||
(userProps) => mergeProps3(userProps, propsList, "floating"),
|
(userProps) => mergeProps3(userProps, propsList, "floating"),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
floatingDeps
|
floatingDeps
|
||||||
);
|
);
|
||||||
const getItemProps = React76.useCallback(
|
const getItemProps = React76.useCallback(
|
||||||
(userProps) => mergeProps3(userProps, propsList, "item"),
|
(userProps) => mergeProps3(userProps, propsList, "item"),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
itemDeps
|
itemDeps
|
||||||
);
|
);
|
||||||
return React76.useMemo(() => ({
|
return React76.useMemo(() => ({
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
838880
.tamagui/tamagui.config.json
838880
.tamagui/tamagui.config.json
File diff suppressed because it is too large
Load Diff
@@ -118,8 +118,319 @@ var isWindowDefined = typeof window < "u";
|
|||||||
var isClient = isWeb && isWindowDefined;
|
var isClient = isWeb && isWindowDefined;
|
||||||
var isChrome = typeof navigator < "u" && /Chrome/.test(navigator.userAgent || "");
|
var isChrome = typeof navigator < "u" && /Chrome/.test(navigator.userAgent || "");
|
||||||
var isWebTouchable = isClient && ("ontouchstart" in window || navigator.maxTouchPoints > 0);
|
var isWebTouchable = isClient && ("ontouchstart" in window || navigator.maxTouchPoints > 0);
|
||||||
|
var isAndroid = false;
|
||||||
var isIos = process.env.TEST_NATIVE_PLATFORM === "ios";
|
var isIos = process.env.TEST_NATIVE_PLATFORM === "ios";
|
||||||
|
|
||||||
|
// node_modules/@tamagui/helpers/dist/esm/validStyleProps.mjs
|
||||||
|
var textColors = {
|
||||||
|
color: true,
|
||||||
|
textDecorationColor: true,
|
||||||
|
textShadowColor: true
|
||||||
|
};
|
||||||
|
var tokenCategories = {
|
||||||
|
radius: {
|
||||||
|
borderRadius: true,
|
||||||
|
borderTopLeftRadius: true,
|
||||||
|
borderTopRightRadius: true,
|
||||||
|
borderBottomLeftRadius: true,
|
||||||
|
borderBottomRightRadius: true,
|
||||||
|
// logical
|
||||||
|
borderStartStartRadius: true,
|
||||||
|
borderStartEndRadius: true,
|
||||||
|
borderEndStartRadius: true,
|
||||||
|
borderEndEndRadius: true
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
width: true,
|
||||||
|
height: true,
|
||||||
|
minWidth: true,
|
||||||
|
minHeight: true,
|
||||||
|
maxWidth: true,
|
||||||
|
maxHeight: true,
|
||||||
|
blockSize: true,
|
||||||
|
minBlockSize: true,
|
||||||
|
maxBlockSize: true,
|
||||||
|
inlineSize: true,
|
||||||
|
minInlineSize: true,
|
||||||
|
maxInlineSize: true
|
||||||
|
},
|
||||||
|
zIndex: {
|
||||||
|
zIndex: true
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
backgroundColor: true,
|
||||||
|
borderColor: true,
|
||||||
|
borderBlockStartColor: true,
|
||||||
|
borderBlockEndColor: true,
|
||||||
|
borderBlockColor: true,
|
||||||
|
borderBottomColor: true,
|
||||||
|
borderInlineColor: true,
|
||||||
|
borderInlineStartColor: true,
|
||||||
|
borderInlineEndColor: true,
|
||||||
|
borderTopColor: true,
|
||||||
|
borderLeftColor: true,
|
||||||
|
borderRightColor: true,
|
||||||
|
borderEndColor: true,
|
||||||
|
borderStartColor: true,
|
||||||
|
shadowColor: true,
|
||||||
|
...textColors,
|
||||||
|
outlineColor: true,
|
||||||
|
caretColor: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var stylePropsUnitless = {
|
||||||
|
WebkitLineClamp: true,
|
||||||
|
animationIterationCount: true,
|
||||||
|
aspectRatio: true,
|
||||||
|
borderImageOutset: true,
|
||||||
|
borderImageSlice: true,
|
||||||
|
borderImageWidth: true,
|
||||||
|
columnCount: true,
|
||||||
|
flex: true,
|
||||||
|
flexGrow: true,
|
||||||
|
flexOrder: true,
|
||||||
|
flexPositive: true,
|
||||||
|
flexShrink: true,
|
||||||
|
flexNegative: true,
|
||||||
|
fontWeight: true,
|
||||||
|
gridRow: true,
|
||||||
|
gridRowEnd: true,
|
||||||
|
gridRowGap: true,
|
||||||
|
gridRowStart: true,
|
||||||
|
gridColumn: true,
|
||||||
|
gridColumnEnd: true,
|
||||||
|
gridColumnGap: true,
|
||||||
|
gridColumnStart: true,
|
||||||
|
gridTemplateColumns: true,
|
||||||
|
gridTemplateAreas: true,
|
||||||
|
lineClamp: true,
|
||||||
|
opacity: true,
|
||||||
|
order: true,
|
||||||
|
orphans: true,
|
||||||
|
tabSize: true,
|
||||||
|
widows: true,
|
||||||
|
zIndex: true,
|
||||||
|
zoom: true,
|
||||||
|
scale: true,
|
||||||
|
scaleX: true,
|
||||||
|
scaleY: true,
|
||||||
|
scaleZ: true,
|
||||||
|
shadowOpacity: true
|
||||||
|
};
|
||||||
|
var stylePropsTransform = {
|
||||||
|
x: true,
|
||||||
|
y: true,
|
||||||
|
scale: true,
|
||||||
|
perspective: true,
|
||||||
|
scaleX: true,
|
||||||
|
scaleY: true,
|
||||||
|
skewX: true,
|
||||||
|
skewY: true,
|
||||||
|
matrix: true,
|
||||||
|
rotate: true,
|
||||||
|
rotateY: true,
|
||||||
|
rotateX: true,
|
||||||
|
rotateZ: true
|
||||||
|
};
|
||||||
|
var stylePropsView = {
|
||||||
|
backfaceVisibility: true,
|
||||||
|
borderBottomEndRadius: true,
|
||||||
|
borderBottomStartRadius: true,
|
||||||
|
borderBottomWidth: true,
|
||||||
|
borderLeftWidth: true,
|
||||||
|
borderRightWidth: true,
|
||||||
|
borderBlockWidth: true,
|
||||||
|
borderBlockEndWidth: true,
|
||||||
|
borderBlockStartWidth: true,
|
||||||
|
borderInlineWidth: true,
|
||||||
|
borderInlineEndWidth: true,
|
||||||
|
borderInlineStartWidth: true,
|
||||||
|
borderStyle: true,
|
||||||
|
borderBlockStyle: true,
|
||||||
|
borderBlockEndStyle: true,
|
||||||
|
borderBlockStartStyle: true,
|
||||||
|
borderInlineStyle: true,
|
||||||
|
borderInlineEndStyle: true,
|
||||||
|
borderInlineStartStyle: true,
|
||||||
|
borderTopEndRadius: true,
|
||||||
|
borderTopStartRadius: true,
|
||||||
|
borderTopWidth: true,
|
||||||
|
borderWidth: true,
|
||||||
|
transform: true,
|
||||||
|
transformOrigin: true,
|
||||||
|
alignContent: true,
|
||||||
|
alignItems: true,
|
||||||
|
alignSelf: true,
|
||||||
|
borderEndWidth: true,
|
||||||
|
borderStartWidth: true,
|
||||||
|
bottom: true,
|
||||||
|
display: true,
|
||||||
|
end: true,
|
||||||
|
flexBasis: true,
|
||||||
|
flexDirection: true,
|
||||||
|
flexWrap: true,
|
||||||
|
gap: true,
|
||||||
|
columnGap: true,
|
||||||
|
rowGap: true,
|
||||||
|
justifyContent: true,
|
||||||
|
left: true,
|
||||||
|
margin: true,
|
||||||
|
marginBlock: true,
|
||||||
|
marginBlockEnd: true,
|
||||||
|
marginBlockStart: true,
|
||||||
|
marginInline: true,
|
||||||
|
marginInlineStart: true,
|
||||||
|
marginInlineEnd: true,
|
||||||
|
marginBottom: true,
|
||||||
|
marginEnd: true,
|
||||||
|
marginHorizontal: true,
|
||||||
|
marginLeft: true,
|
||||||
|
marginRight: true,
|
||||||
|
marginStart: true,
|
||||||
|
marginTop: true,
|
||||||
|
marginVertical: true,
|
||||||
|
overflow: true,
|
||||||
|
padding: true,
|
||||||
|
paddingBottom: true,
|
||||||
|
paddingInline: true,
|
||||||
|
paddingBlock: true,
|
||||||
|
paddingBlockStart: true,
|
||||||
|
paddingInlineEnd: true,
|
||||||
|
paddingInlineStart: true,
|
||||||
|
paddingEnd: true,
|
||||||
|
paddingHorizontal: true,
|
||||||
|
paddingLeft: true,
|
||||||
|
paddingRight: true,
|
||||||
|
paddingStart: true,
|
||||||
|
paddingTop: true,
|
||||||
|
paddingVertical: true,
|
||||||
|
position: true,
|
||||||
|
right: true,
|
||||||
|
start: true,
|
||||||
|
top: true,
|
||||||
|
inset: true,
|
||||||
|
insetBlock: true,
|
||||||
|
insetBlockEnd: true,
|
||||||
|
insetBlockStart: true,
|
||||||
|
insetInline: true,
|
||||||
|
insetInlineEnd: true,
|
||||||
|
insetInlineStart: true,
|
||||||
|
direction: true,
|
||||||
|
shadowOffset: true,
|
||||||
|
shadowRadius: true,
|
||||||
|
...tokenCategories.color,
|
||||||
|
...tokenCategories.radius,
|
||||||
|
...tokenCategories.size,
|
||||||
|
...tokenCategories.radius,
|
||||||
|
...stylePropsTransform,
|
||||||
|
...stylePropsUnitless,
|
||||||
|
boxShadow: true,
|
||||||
|
filter: true,
|
||||||
|
// RN 0.77+ style props (set REACT_NATIVE_PRE_77=1 for older RN)
|
||||||
|
...!process.env.REACT_NATIVE_PRE_77 && {
|
||||||
|
boxSizing: true,
|
||||||
|
mixBlendMode: true,
|
||||||
|
outlineColor: true,
|
||||||
|
outlineSpread: true,
|
||||||
|
outlineStyle: true,
|
||||||
|
outlineWidth: true
|
||||||
|
},
|
||||||
|
// RN doesn't support specific border styles per-edge
|
||||||
|
transition: true,
|
||||||
|
textWrap: true,
|
||||||
|
backdropFilter: true,
|
||||||
|
WebkitBackdropFilter: true,
|
||||||
|
background: true,
|
||||||
|
backgroundAttachment: true,
|
||||||
|
backgroundBlendMode: true,
|
||||||
|
backgroundClip: true,
|
||||||
|
backgroundColor: true,
|
||||||
|
backgroundImage: true,
|
||||||
|
backgroundOrigin: true,
|
||||||
|
backgroundPosition: true,
|
||||||
|
backgroundRepeat: true,
|
||||||
|
backgroundSize: true,
|
||||||
|
borderBottomStyle: true,
|
||||||
|
borderImage: true,
|
||||||
|
borderLeftStyle: true,
|
||||||
|
borderRightStyle: true,
|
||||||
|
borderTopStyle: true,
|
||||||
|
caretColor: true,
|
||||||
|
clipPath: true,
|
||||||
|
contain: true,
|
||||||
|
containerType: true,
|
||||||
|
content: true,
|
||||||
|
cursor: true,
|
||||||
|
float: true,
|
||||||
|
mask: true,
|
||||||
|
maskBorder: true,
|
||||||
|
maskBorderMode: true,
|
||||||
|
maskBorderOutset: true,
|
||||||
|
maskBorderRepeat: true,
|
||||||
|
maskBorderSlice: true,
|
||||||
|
maskBorderSource: true,
|
||||||
|
maskBorderWidth: true,
|
||||||
|
maskClip: true,
|
||||||
|
maskComposite: true,
|
||||||
|
maskImage: true,
|
||||||
|
maskMode: true,
|
||||||
|
maskOrigin: true,
|
||||||
|
maskPosition: true,
|
||||||
|
maskRepeat: true,
|
||||||
|
maskSize: true,
|
||||||
|
maskType: true,
|
||||||
|
objectFit: true,
|
||||||
|
objectPosition: true,
|
||||||
|
outlineOffset: true,
|
||||||
|
overflowBlock: true,
|
||||||
|
overflowInline: true,
|
||||||
|
overflowX: true,
|
||||||
|
overflowY: true,
|
||||||
|
pointerEvents: true,
|
||||||
|
scrollbarWidth: true,
|
||||||
|
textEmphasis: true,
|
||||||
|
touchAction: true,
|
||||||
|
transformStyle: true,
|
||||||
|
userSelect: true,
|
||||||
|
willChange: true,
|
||||||
|
...isAndroid ? {
|
||||||
|
elevationAndroid: true
|
||||||
|
} : {}
|
||||||
|
};
|
||||||
|
var stylePropsFont = {
|
||||||
|
fontFamily: true,
|
||||||
|
fontSize: true,
|
||||||
|
fontStyle: true,
|
||||||
|
fontWeight: true,
|
||||||
|
fontVariant: true,
|
||||||
|
letterSpacing: true,
|
||||||
|
lineHeight: true,
|
||||||
|
textTransform: true
|
||||||
|
};
|
||||||
|
var stylePropsTextOnly = {
|
||||||
|
...stylePropsFont,
|
||||||
|
textAlign: true,
|
||||||
|
textDecorationLine: true,
|
||||||
|
textDecorationStyle: true,
|
||||||
|
...textColors,
|
||||||
|
textShadowOffset: true,
|
||||||
|
textShadowRadius: true,
|
||||||
|
userSelect: true,
|
||||||
|
selectable: true,
|
||||||
|
verticalAlign: true,
|
||||||
|
whiteSpace: true,
|
||||||
|
wordWrap: true,
|
||||||
|
textOverflow: true,
|
||||||
|
textDecorationDistance: true,
|
||||||
|
cursor: true,
|
||||||
|
WebkitLineClamp: true,
|
||||||
|
WebkitBoxOrient: true
|
||||||
|
};
|
||||||
|
var stylePropsText = {
|
||||||
|
...stylePropsView,
|
||||||
|
...stylePropsTextOnly
|
||||||
|
};
|
||||||
|
|
||||||
// node_modules/@tamagui/helpers/dist/esm/withStaticProperties.mjs
|
// node_modules/@tamagui/helpers/dist/esm/withStaticProperties.mjs
|
||||||
var import_react2 = __toESM(require("react"), 1);
|
var import_react2 = __toESM(require("react"), 1);
|
||||||
var Decorated = Symbol();
|
var Decorated = Symbol();
|
||||||
@@ -444,10 +755,7 @@ var SizableText2 = (0, import_web4.styled)(import_web4.Text, {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
SizableText2.staticConfig.variants.fontFamily = {
|
SizableText2.staticConfig.variants.fontFamily = {
|
||||||
"...": /* @__PURE__ */ __name((val, extras) => {
|
"...": /* @__PURE__ */ __name((_val, extras) => {
|
||||||
if (val === "inherit") return {
|
|
||||||
fontFamily: "inherit"
|
|
||||||
};
|
|
||||||
const sizeProp = extras.props.size, fontSizeProp = extras.props.fontSize, size = sizeProp === "$true" && fontSizeProp ? fontSizeProp : extras.props.size || "$true";
|
const sizeProp = extras.props.size, fontSizeProp = extras.props.fontSize, size = sizeProp === "$true" && fontSizeProp ? fontSizeProp : extras.props.size || "$true";
|
||||||
return getFontSized(size, extras);
|
return getFontSized(size, extras);
|
||||||
}, "...")
|
}, "...")
|
||||||
|
|||||||
@@ -112,10 +112,7 @@ var SizableText2 = (0, import_web2.styled)(import_web2.Text, {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
SizableText2.staticConfig.variants.fontFamily = {
|
SizableText2.staticConfig.variants.fontFamily = {
|
||||||
"...": /* @__PURE__ */ __name((val, extras) => {
|
"...": /* @__PURE__ */ __name((_val, extras) => {
|
||||||
if (val === "inherit") return {
|
|
||||||
fontFamily: "inherit"
|
|
||||||
};
|
|
||||||
const sizeProp = extras.props.size, fontSizeProp = extras.props.fontSize, size = sizeProp === "$true" && fontSizeProp ? fontSizeProp : extras.props.size || "$true";
|
const sizeProp = extras.props.size, fontSizeProp = extras.props.fontSize, size = sizeProp === "$true" && fontSizeProp ? fontSizeProp : extras.props.size || "$true";
|
||||||
return getFontSized(size, extras);
|
return getFontSized(size, extras);
|
||||||
}, "...")
|
}, "...")
|
||||||
|
|||||||
212
AGENTS.md
212
AGENTS.md
@@ -38,9 +38,6 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
|||||||
- resources/js/admin/ — Tenant Admin PWA source (React 19, Capacitor/TWA ready).
|
- resources/js/admin/ — Tenant Admin PWA source (React 19, Capacitor/TWA ready).
|
||||||
- resources/js/pages/ — Inertia pages (React).
|
- resources/js/pages/ — Inertia pages (React).
|
||||||
- docs/archive/README.md — historical PRP context.
|
- docs/archive/README.md — historical PRP context.
|
||||||
- Marketing frontend language files:
|
|
||||||
- Source translations: `resources/lang/{de,en}/marketing.php` and `resources/lang/{de,en}/marketing.json`.
|
|
||||||
- Runtime i18next JSON served to the frontend: `public/lang/{de,en}/marketing.json` (must stay in sync with the source files).
|
|
||||||
|
|
||||||
## Standard Workflows
|
## Standard Workflows
|
||||||
- Coding tasks (Codegen Agent):
|
- Coding tasks (Codegen Agent):
|
||||||
@@ -132,7 +129,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
|
|||||||
## Foundational Context
|
## Foundational Context
|
||||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||||
|
|
||||||
- php - 8.3.6
|
- php - 8.3.24
|
||||||
- filament/filament (FILAMENT) - v4
|
- filament/filament (FILAMENT) - v4
|
||||||
- inertiajs/inertia-laravel (INERTIA) - v2
|
- inertiajs/inertia-laravel (INERTIA) - v2
|
||||||
- laravel/framework (LARAVEL) - v12
|
- laravel/framework (LARAVEL) - v12
|
||||||
@@ -154,7 +151,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
- prettier (PRETTIER) - v3
|
- prettier (PRETTIER) - v3
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
|
||||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||||
- Check for existing components to reuse before writing a new one.
|
- Check for existing components to reuse before writing a new one.
|
||||||
|
|
||||||
@@ -162,7 +159,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
|
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
|
||||||
|
|
||||||
## Application Structure & Architecture
|
## Application Structure & Architecture
|
||||||
- Stick to existing directory structure; don't create new base folders without approval.
|
- Stick to existing directory structure - don't create new base folders without approval.
|
||||||
- Do not change the application's dependencies without approval.
|
- Do not change the application's dependencies without approval.
|
||||||
|
|
||||||
## Frontend Bundling
|
## Frontend Bundling
|
||||||
@@ -174,16 +171,17 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
## Documentation Files
|
## Documentation Files
|
||||||
- You must only create documentation files if explicitly requested by the user.
|
- You must only create documentation files if explicitly requested by the user.
|
||||||
|
|
||||||
|
|
||||||
=== boost rules ===
|
=== boost rules ===
|
||||||
|
|
||||||
## Laravel Boost
|
## Laravel Boost
|
||||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||||
|
|
||||||
## Artisan
|
## Artisan
|
||||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
|
||||||
|
|
||||||
## URLs
|
## URLs
|
||||||
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
|
||||||
|
|
||||||
## Tinker / Debugging
|
## Tinker / Debugging
|
||||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||||
@@ -194,21 +192,22 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
- Only recent browser logs will be useful - ignore old logs.
|
- Only recent browser logs will be useful - ignore old logs.
|
||||||
|
|
||||||
## Searching Documentation (Critically Important)
|
## Searching Documentation (Critically Important)
|
||||||
- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||||
- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
||||||
- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
|
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
|
||||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||||
- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
||||||
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||||
|
|
||||||
### Available Search Syntax
|
### Available Search Syntax
|
||||||
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
||||||
|
|
||||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
|
||||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
|
||||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
|
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
|
||||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
|
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
|
||||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
|
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
|
||||||
|
|
||||||
|
|
||||||
=== php rules ===
|
=== php rules ===
|
||||||
|
|
||||||
@@ -219,7 +218,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
### Constructors
|
### Constructors
|
||||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||||
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
- Do not allow empty `__construct()` methods with zero parameters.
|
||||||
|
|
||||||
### Type Declarations
|
### Type Declarations
|
||||||
- Always use explicit return type declarations for methods and functions.
|
- Always use explicit return type declarations for methods and functions.
|
||||||
@@ -233,7 +232,7 @@ protected function isAccessible(User $user, ?string $path = null): bool
|
|||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
## Comments
|
## Comments
|
||||||
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
|
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
|
||||||
|
|
||||||
## PHPDoc Blocks
|
## PHPDoc Blocks
|
||||||
- Add useful array shape type definitions for arrays when appropriate.
|
- Add useful array shape type definitions for arrays when appropriate.
|
||||||
@@ -241,22 +240,32 @@ protected function isAccessible(User $user, ?string $path = null): bool
|
|||||||
## Enums
|
## Enums
|
||||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||||
|
|
||||||
|
|
||||||
|
=== herd rules ===
|
||||||
|
|
||||||
|
## Laravel Herd
|
||||||
|
|
||||||
|
- The application is served by Laravel Herd and will be available at: https?://[kebab-case-project-dir].test. Use the `get-absolute-url` tool to generate URLs for the user to ensure valid URLs.
|
||||||
|
- You must not run any commands to make the site available via HTTP(s). It is _always_ available through Laravel Herd.
|
||||||
|
|
||||||
|
|
||||||
=== tests rules ===
|
=== tests rules ===
|
||||||
|
|
||||||
## Test Enforcement
|
## Test Enforcement
|
||||||
|
|
||||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
|
||||||
|
|
||||||
|
|
||||||
=== inertia-laravel/core rules ===
|
=== inertia-laravel/core rules ===
|
||||||
|
|
||||||
## Inertia
|
## Inertia Core
|
||||||
|
|
||||||
- Inertia.js components should be placed in the `resources/js/Pages` directory unless specified differently in the JS bundler (`vite.config.js`).
|
- Inertia.js components should be placed in the `resources/js/pages` directory unless specified differently in the JS bundler (vite.config.js).
|
||||||
- Use `Inertia::render()` for server-side routing instead of traditional Blade views.
|
- Use `Inertia::render()` for server-side routing instead of traditional Blade views.
|
||||||
- Use the `search-docs` tool for accurate guidance on all things Inertia.
|
- Use `search-docs` for accurate guidance on all things Inertia.
|
||||||
|
|
||||||
<code-snippet name="Inertia Render Example" lang="php">
|
<code-snippet lang="php" name="Inertia::render Example">
|
||||||
// routes/web.php example
|
// routes/web.php example
|
||||||
Route::get('/users', function () {
|
Route::get('/users', function () {
|
||||||
return Inertia::render('Users/Index', [
|
return Inertia::render('Users/Index', [
|
||||||
@@ -265,26 +274,28 @@ Route::get('/users', function () {
|
|||||||
});
|
});
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
=== inertia-laravel/v2 rules ===
|
=== inertia-laravel/v2 rules ===
|
||||||
|
|
||||||
## Inertia v2
|
## Inertia v2
|
||||||
|
|
||||||
- Make use of all Inertia features from v1 and v2. Check the documentation before making any changes to ensure we are taking the correct approach.
|
- Make use of all Inertia features from v1 & v2. Check the documentation before making any changes to ensure we are taking the correct approach.
|
||||||
|
|
||||||
### Inertia v2 New Features
|
### Inertia v2 New Features
|
||||||
- Deferred props.
|
- Polling
|
||||||
- Infinite scrolling using merging props and `WhenVisible`.
|
- Prefetching
|
||||||
- Lazy loading data on scroll.
|
- Deferred props
|
||||||
- Polling.
|
- Infinite scrolling using merging props and `WhenVisible`
|
||||||
- Prefetching.
|
- Lazy loading data on scroll
|
||||||
|
|
||||||
### Deferred Props & Empty States
|
### Deferred Props & Empty States
|
||||||
- When using deferred props on the frontend, you should add a nice empty state with pulsing/animated skeleton.
|
- When using deferred props on the frontend, you should add a nice empty state with pulsing / animated skeleton.
|
||||||
|
|
||||||
### Inertia Form General Guidance
|
### Inertia Form General Guidance
|
||||||
- The recommended way to build forms when using Inertia is with the `<Form>` component - a useful example is below. Use the `search-docs` tool with a query of `form component` for guidance.
|
- The recommended way to build forms when using Inertia is with the `<Form>` component - a useful example is below. Use `search-docs` with a query of `form component` for guidance.
|
||||||
- Forms can also be built using the `useForm` helper for more programmatic control, or to follow existing conventions. Use the `search-docs` tool with a query of `useForm helper` for guidance.
|
- Forms can also be built using the `useForm` helper for more programmatic control, or to follow existing conventions. Use `search-docs` with a query of `useForm helper` for guidance.
|
||||||
- `resetOnError`, `resetOnSuccess`, and `setDefaultsOnSuccess` are available on the `<Form>` component. Use the `search-docs` tool with a query of `form component resetting` for guidance.
|
- `resetOnError`, `resetOnSuccess`, and `setDefaultsOnSuccess` are available on the `<Form>` component. Use `search-docs` with a query of 'form component resetting' for guidance.
|
||||||
|
|
||||||
|
|
||||||
=== laravel/core rules ===
|
=== laravel/core rules ===
|
||||||
|
|
||||||
@@ -296,7 +307,7 @@ Route::get('/users', function () {
|
|||||||
|
|
||||||
### Database
|
### Database
|
||||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||||
- Use Eloquent models and relationships before suggesting raw database queries.
|
- Use Eloquent models and relationships before suggesting raw database queries
|
||||||
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||||
- Generate code that prevents N+1 query problems by using eager loading.
|
- Generate code that prevents N+1 query problems by using eager loading.
|
||||||
- Use Laravel's query builder for very complex database operations.
|
- Use Laravel's query builder for very complex database operations.
|
||||||
@@ -331,56 +342,52 @@ Route::get('/users', function () {
|
|||||||
### Vite Error
|
### Vite Error
|
||||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||||
|
|
||||||
|
|
||||||
=== laravel/v12 rules ===
|
=== laravel/v12 rules ===
|
||||||
|
|
||||||
## Laravel 12
|
## Laravel 12
|
||||||
|
|
||||||
- Use the `search-docs` tool to get version-specific documentation.
|
- Use the `search-docs` tool to get version specific documentation.
|
||||||
- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure.
|
- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure.
|
||||||
- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not need to migrate to the new Laravel structure unless the user explicitly requests it.
|
- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not to need migrate to the new Laravel structure unless the user explicitly requests that.
|
||||||
|
|
||||||
|
|
||||||
### Laravel 10 Structure
|
|
||||||
- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`.
|
|
||||||
- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure:
|
|
||||||
- Middleware registration happens in `app/Http/Kernel.php`
|
|
||||||
- Exception handling is in `app/Exceptions/Handler.php`
|
|
||||||
- Console commands and schedule register in `app/Console/Kernel.php`
|
|
||||||
- Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php`
|
|
||||||
|
|
||||||
### Database
|
### Database
|
||||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||||
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||||
|
|
||||||
### Models
|
### Models
|
||||||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||||
|
|
||||||
|
|
||||||
=== wayfinder/core rules ===
|
=== wayfinder/core rules ===
|
||||||
|
|
||||||
## Laravel Wayfinder
|
## Laravel Wayfinder
|
||||||
|
|
||||||
Wayfinder generates TypeScript functions and types for Laravel controllers and routes which you can import into your client-side code. It provides type safety and automatic synchronization between backend routes and frontend code.
|
Wayfinder generates TypeScript functions and types for Laravel controllers and routes which you can import into your client side code. It provides type safety and automatic synchronization between backend routes and frontend code.
|
||||||
|
|
||||||
### Development Guidelines
|
### Development Guidelines
|
||||||
- Always use the `search-docs` tool to check Wayfinder correct usage before implementing any features.
|
- Always use `search-docs` to check wayfinder correct usage before implementing any features.
|
||||||
- Always prefer named imports for tree-shaking (e.g., `import { show } from '@/actions/...'`).
|
- Always Prefer named imports for tree-shaking (e.g., `import { show } from '@/actions/...'`)
|
||||||
- Avoid default controller imports (prevents tree-shaking).
|
- Avoid default controller imports (prevents tree-shaking)
|
||||||
- Run `php artisan wayfinder:generate` after route changes if Vite plugin isn't installed.
|
- Run `php artisan wayfinder:generate` after route changes if Vite plugin isn't installed
|
||||||
|
|
||||||
### Feature Overview
|
### Feature Overview
|
||||||
- Form Support: Use `.form()` with `--with-form` flag for HTML form attributes — `<form {...store.form()}>` → `action="/posts" method="post"`.
|
- Form Support: Use `.form()` with `--with-form` flag for HTML form attributes — `<form {...store.form()}>` → `action="/posts" method="post"`
|
||||||
- HTTP Methods: Call `.get()`, `.post()`, `.patch()`, `.put()`, `.delete()` for specific methods — `show.head(1)` → `{ url: "/posts/1", method: "head" }`.
|
- HTTP Methods: Call `.get()`, `.post()`, `.patch()`, `.put()`, `.delete()` for specific methods — `show.head(1)` → `{ url: "/posts/1", method: "head" }`
|
||||||
- Invokable Controllers: Import and invoke directly as functions. For example, `import StorePost from '@/actions/.../StorePostController'; StorePost()`.
|
- Invokable Controllers: Import and invoke directly as functions. For example, `import StorePost from '@/actions/.../StorePostController'; StorePost()`
|
||||||
- Named Routes: Import from `@/routes/` for non-controller routes. For example, `import { show } from '@/routes/post'; show(1)` for route name `post.show`.
|
- Named Routes: Import from `@/routes/` for non-controller routes. For example, `import { show } from '@/routes/post'; show(1)` for route name `post.show`
|
||||||
- Parameter Binding: Detects route keys (e.g., `{post:slug}`) and accepts matching object properties — `show("my-post")` or `show({ slug: "my-post" })`.
|
- Parameter Binding: Detects route keys (e.g., `{post:slug}`) and accepts matching object properties — `show("my-post")` or `show({ slug: "my-post" })`
|
||||||
- Query Merging: Use `mergeQuery` to merge with `window.location.search`, set values to `null` to remove — `show(1, { mergeQuery: { page: 2, sort: null } })`.
|
- Query Merging: Use `mergeQuery` to merge with `window.location.search`, set values to `null` to remove — `show(1, { mergeQuery: { page: 2, sort: null } })`
|
||||||
- Query Parameters: Pass `{ query: {...} }` in options to append params — `show(1, { query: { page: 1 } })` → `"/posts/1?page=1"`.
|
- Query Parameters: Pass `{ query: {...} }` in options to append params — `show(1, { query: { page: 1 } })` → `"/posts/1?page=1"`
|
||||||
- Route Objects: Functions return `{ url, method }` shaped objects — `show(1)` → `{ url: "/posts/1", method: "get" }`.
|
- Route Objects: Functions return `{ url, method }` shaped objects — `show(1)` → `{ url: "/posts/1", method: "get" }`
|
||||||
- URL Extraction: Use `.url()` to get URL string — `show.url(1)` → `"/posts/1"`.
|
- URL Extraction: Use `.url()` to get URL string — `show.url(1)` → `"/posts/1"`
|
||||||
|
|
||||||
### Example Usage
|
### Example Usage
|
||||||
|
|
||||||
<code-snippet name="Wayfinder Basic Usage" lang="typescript">
|
<code-snippet name="Wayfinder Basic Usage" lang="typescript">
|
||||||
// Import controller methods (tree-shakable)...
|
// Import controller methods (tree-shakable)
|
||||||
import { show, store, update } from '@/actions/App/Http/Controllers/PostController'
|
import { show, store, update } from '@/actions/App/Http/Controllers/PostController'
|
||||||
|
|
||||||
// Get route object with URL and method...
|
// Get route object with URL and method...
|
||||||
@@ -398,6 +405,7 @@ Wayfinder generates TypeScript functions and types for Laravel controllers and r
|
|||||||
postShow(1) // { url: "/posts/1", method: "get" }
|
postShow(1) // { url: "/posts/1", method: "get" }
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
### Wayfinder + Inertia
|
### Wayfinder + Inertia
|
||||||
If your application uses the `<Form>` component from Inertia, you can use Wayfinder to generate form action and method automatically.
|
If your application uses the `<Form>` component from Inertia, you can use Wayfinder to generate form action and method automatically.
|
||||||
<code-snippet name="Wayfinder Form Component (React)" lang="typescript">
|
<code-snippet name="Wayfinder Form Component (React)" lang="typescript">
|
||||||
@@ -406,14 +414,14 @@ If your application uses the `<Form>` component from Inertia, you can use Wayfin
|
|||||||
|
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
=== livewire/core rules ===
|
=== livewire/core rules ===
|
||||||
|
|
||||||
## Livewire
|
## Livewire Core
|
||||||
|
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
|
||||||
- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
|
- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components
|
||||||
- Use the `php artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
|
|
||||||
- State should live on the server, with the UI reflecting it.
|
- State should live on the server, with the UI reflecting it.
|
||||||
- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
|
- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
|
||||||
|
|
||||||
## Livewire Best Practices
|
## Livewire Best Practices
|
||||||
- Livewire components require a single root element.
|
- Livewire components require a single root element.
|
||||||
@@ -430,14 +438,15 @@ If your application uses the `<Form>` component from Inertia, you can use Wayfin
|
|||||||
|
|
||||||
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
|
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
|
||||||
|
|
||||||
<code-snippet name="Lifecycle Hook Examples" lang="php">
|
<code-snippet name="Lifecycle hook examples" lang="php">
|
||||||
public function mount(User $user) { $this->user = $user; }
|
public function mount(User $user) { $this->user = $user; }
|
||||||
public function updatedSearch() { $this->resetPage(); }
|
public function updatedSearch() { $this->resetPage(); }
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
## Testing Livewire
|
## Testing Livewire
|
||||||
|
|
||||||
<code-snippet name="Example Livewire Component Test" lang="php">
|
<code-snippet name="Example Livewire component test" lang="php">
|
||||||
Livewire::test(Counter::class)
|
Livewire::test(Counter::class)
|
||||||
->assertSet('count', 0)
|
->assertSet('count', 0)
|
||||||
->call('increment')
|
->call('increment')
|
||||||
@@ -446,17 +455,19 @@ If your application uses the `<Form>` component from Inertia, you can use Wayfin
|
|||||||
->assertStatus(200);
|
->assertStatus(200);
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
|
|
||||||
$this->get('/posts/create')
|
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
|
||||||
->assertSeeLivewire(CreatePost::class);
|
$this->get('/posts/create')
|
||||||
</code-snippet>
|
->assertSeeLivewire(CreatePost::class);
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
=== livewire/v3 rules ===
|
=== livewire/v3 rules ===
|
||||||
|
|
||||||
## Livewire 3
|
## Livewire 3
|
||||||
|
|
||||||
### Key Changes From Livewire 2
|
### Key Changes From Livewire 2
|
||||||
- These things changed in Livewire 3, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
|
- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
|
||||||
- Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
|
- Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
|
||||||
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
|
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
|
||||||
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
|
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
|
||||||
@@ -466,13 +477,13 @@ If your application uses the `<Form>` component from Inertia, you can use Wayfin
|
|||||||
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
|
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
|
||||||
|
|
||||||
### Alpine
|
### Alpine
|
||||||
- Alpine is now included with Livewire; don't manually include Alpine.js.
|
- Alpine is now included with Livewire, don't manually include Alpine.js.
|
||||||
- Plugins included with Alpine: persist, intersect, collapse, and focus.
|
- Plugins included with Alpine: persist, intersect, collapse, and focus.
|
||||||
|
|
||||||
### Lifecycle Hooks
|
### Lifecycle Hooks
|
||||||
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
|
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
|
||||||
|
|
||||||
<code-snippet name="Livewire Init Hook Example" lang="js">
|
<code-snippet name="livewire:load example" lang="js">
|
||||||
document.addEventListener('livewire:init', function () {
|
document.addEventListener('livewire:init', function () {
|
||||||
Livewire.hook('request', ({ fail }) => {
|
Livewire.hook('request', ({ fail }) => {
|
||||||
if (fail && fail.status === 419) {
|
if (fail && fail.status === 419) {
|
||||||
@@ -486,6 +497,7 @@ document.addEventListener('livewire:init', function () {
|
|||||||
});
|
});
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
=== pint/core rules ===
|
=== pint/core rules ===
|
||||||
|
|
||||||
## Laravel Pint Code Formatter
|
## Laravel Pint Code Formatter
|
||||||
@@ -493,22 +505,24 @@ document.addEventListener('livewire:init', function () {
|
|||||||
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
||||||
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
|
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
|
||||||
|
|
||||||
|
|
||||||
=== phpunit/core rules ===
|
=== phpunit/core rules ===
|
||||||
|
|
||||||
## PHPUnit
|
## PHPUnit Core
|
||||||
|
|
||||||
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
|
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
|
||||||
- If you see a test using "Pest", convert it to PHPUnit.
|
- If you see a test using "Pest", convert it to PHPUnit.
|
||||||
- Every time a test has been updated, run that singular test.
|
- Every time a test has been updated, run that singular test.
|
||||||
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
|
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
|
||||||
- Tests should test all of the happy paths, failure paths, and weird paths.
|
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
|
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files, these are core to the application.
|
||||||
|
|
||||||
### Running Tests
|
### Running Tests
|
||||||
- Run the minimal number of tests, using an appropriate filter, before finalizing.
|
- Run the minimal number of tests, using an appropriate filter, before finalizing.
|
||||||
- To run all tests: `php artisan test --compact`.
|
- To run all tests: `php artisan test`.
|
||||||
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
|
||||||
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
|
||||||
|
|
||||||
|
|
||||||
=== inertia-react/core rules ===
|
=== inertia-react/core rules ===
|
||||||
|
|
||||||
@@ -523,9 +537,10 @@ import { Link } from '@inertiajs/react'
|
|||||||
|
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
=== inertia-react/v2/forms rules ===
|
=== inertia-react/v2/forms rules ===
|
||||||
|
|
||||||
## Inertia v2 + React Forms
|
## Inertia + React Forms
|
||||||
|
|
||||||
<code-snippet name="`<Form>` Component Example" lang="react">
|
<code-snippet name="`<Form>` Component Example" lang="react">
|
||||||
|
|
||||||
@@ -560,37 +575,39 @@ export default () => (
|
|||||||
|
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
=== tailwindcss/core rules ===
|
=== tailwindcss/core rules ===
|
||||||
|
|
||||||
## Tailwind CSS
|
## Tailwind Core
|
||||||
|
|
||||||
- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
|
- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
|
||||||
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
|
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
|
||||||
- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
|
- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
|
||||||
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
|
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
|
||||||
|
|
||||||
### Spacing
|
### Spacing
|
||||||
- When listing items, use gap utilities for spacing; don't use margins.
|
- When listing items, use gap utilities for spacing, don't use margins.
|
||||||
|
|
||||||
|
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<div>Superior</div>
|
||||||
|
<div>Michigan</div>
|
||||||
|
<div>Erie</div>
|
||||||
|
</div>
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
|
|
||||||
<div class="flex gap-8">
|
|
||||||
<div>Superior</div>
|
|
||||||
<div>Michigan</div>
|
|
||||||
<div>Erie</div>
|
|
||||||
</div>
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
### Dark Mode
|
### Dark Mode
|
||||||
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
|
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
|
||||||
|
|
||||||
|
|
||||||
=== tailwindcss/v4 rules ===
|
=== tailwindcss/v4 rules ===
|
||||||
|
|
||||||
## Tailwind CSS 4
|
## Tailwind 4
|
||||||
|
|
||||||
- Always use Tailwind CSS v4; do not use the deprecated utilities.
|
- Always use Tailwind CSS v4 - do not use the deprecated utilities.
|
||||||
- `corePlugins` is not supported in Tailwind v4.
|
- `corePlugins` is not supported in Tailwind v4.
|
||||||
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
|
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
|
||||||
|
|
||||||
<code-snippet name="Extending Theme in CSS" lang="css">
|
<code-snippet name="Extending Theme in CSS" lang="css">
|
||||||
@theme {
|
@theme {
|
||||||
--color-brand: oklch(0.72 0.11 178);
|
--color-brand: oklch(0.72 0.11 178);
|
||||||
@@ -606,8 +623,9 @@ export default () => (
|
|||||||
+ @import "tailwindcss";
|
+ @import "tailwindcss";
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
### Replaced Utilities
|
### Replaced Utilities
|
||||||
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
|
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
|
||||||
- Opacity values are still numeric.
|
- Opacity values are still numeric.
|
||||||
|
|
||||||
| Deprecated | Replacement |
|
| Deprecated | Replacement |
|
||||||
|
|||||||
@@ -100,8 +100,6 @@ COPY . .
|
|||||||
COPY --from=vendor /var/www/html/vendor ./vendor
|
COPY --from=vendor /var/www/html/vendor ./vendor
|
||||||
COPY --from=node_builder /var/www/html/public/build ./public/build
|
COPY --from=node_builder /var/www/html/public/build ./public/build
|
||||||
|
|
||||||
RUN php artisan vendor:publish --tag=livewire:assets --force --no-interaction
|
|
||||||
|
|
||||||
RUN php artisan config:clear \
|
RUN php artisan config:clear \
|
||||||
&& php artisan config:cache \
|
&& php artisan config:cache \
|
||||||
&& php artisan route:clear \
|
&& php artisan route:clear \
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Models\Tenant;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Console\Attributes\AsCommand;
|
use Illuminate\Console\Attributes\AsCommand;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
#[AsCommand(name: 'tenant:attach-demo-event')]
|
#[AsCommand(name: 'tenant:attach-demo-event')]
|
||||||
class AttachDemoEvent extends Command
|
class AttachDemoEvent extends Command
|
||||||
@@ -24,12 +25,10 @@ class AttachDemoEvent extends Command
|
|||||||
{
|
{
|
||||||
if (! \Illuminate\Support\Facades\Schema::hasTable('events')) {
|
if (! \Illuminate\Support\Facades\Schema::hasTable('events')) {
|
||||||
$this->error("Table 'events' does not exist. Run: php artisan migrate");
|
$this->error("Table 'events' does not exist. Run: php artisan migrate");
|
||||||
|
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
if (! \Illuminate\Support\Facades\Schema::hasColumn('events', 'tenant_id')) {
|
if (! \Illuminate\Support\Facades\Schema::hasColumn('events', 'tenant_id')) {
|
||||||
$this->error("Column 'events.tenant_id' does not exist. Add it and rerun. Suggested: create a migration to add a nullable foreignId to tenants.");
|
$this->error("Column 'events.tenant_id' does not exist. Add it and rerun. Suggested: create a migration to add a nullable foreignId to tenants.");
|
||||||
|
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
$tenant = null;
|
$tenant = null;
|
||||||
@@ -46,7 +45,6 @@ class AttachDemoEvent extends Command
|
|||||||
}
|
}
|
||||||
if (! $tenant) {
|
if (! $tenant) {
|
||||||
$this->error('Tenant not found. Provide --tenant-slug or a user with tenant_id via --tenant-email.');
|
$this->error('Tenant not found. Provide --tenant-slug or a user with tenant_id via --tenant-email.');
|
||||||
|
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,14 +67,12 @@ class AttachDemoEvent extends Command
|
|||||||
|
|
||||||
if (! $event) {
|
if (! $event) {
|
||||||
$this->error('Event not found. Provide --event-id or --event-slug.');
|
$this->error('Event not found. Provide --event-id or --event-slug.');
|
||||||
|
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idempotent update
|
// Idempotent update
|
||||||
if ((int) $event->tenant_id === (int) $tenant->id) {
|
if ((int) $event->tenant_id === (int) $tenant->id) {
|
||||||
$this->info("Event #{$event->id} already attached to tenant #{$tenant->id} ({$tenant->slug}).");
|
$this->info("Event #{$event->id} already attached to tenant #{$tenant->id} ({$tenant->slug}).");
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +80,6 @@ class AttachDemoEvent extends Command
|
|||||||
$event->save();
|
$event->save();
|
||||||
|
|
||||||
$this->info("Attached event #{$event->id} ({$event->slug}) to tenant #{$tenant->id} ({$tenant->slug}).");
|
$this->info("Attached event #{$event->id} ({$event->slug}) to tenant #{$tenant->id} ({$tenant->slug}).");
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,27 +10,22 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
class BackfillThumbnails extends Command
|
class BackfillThumbnails extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'media:backfill-thumbnails {--limit=500}';
|
protected $signature = 'media:backfill-thumbnails {--limit=500}';
|
||||||
|
|
||||||
protected $description = 'Generate thumbnails for photos missing thumbnail_path or where thumbnail equals original.';
|
protected $description = 'Generate thumbnails for photos missing thumbnail_path or where thumbnail equals original.';
|
||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
$limit = (int) $this->option('limit');
|
$limit = (int) $this->option('limit');
|
||||||
$rows = DB::table('photos')
|
$rows = DB::table('photos')
|
||||||
->select(['id', 'event_id', 'file_path', 'thumbnail_path'])
|
->select(['id','event_id','file_path','thumbnail_path'])
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->limit($limit)
|
->limit($limit)
|
||||||
->get();
|
->get();
|
||||||
$count = 0;
|
$count = 0;
|
||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
$orig = $this->relativeFromUrl((string) $r->file_path);
|
$orig = $this->relativeFromUrl((string)$r->file_path);
|
||||||
$thumb = (string) ($r->thumbnail_path ?? '');
|
$thumb = (string)($r->thumbnail_path ?? '');
|
||||||
if ($thumb && $thumb !== $r->file_path) {
|
if ($thumb && $thumb !== $r->file_path) continue; // already set to different thumb
|
||||||
continue;
|
if (! $orig) continue;
|
||||||
} // already set to different thumb
|
|
||||||
if (! $orig) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$baseName = pathinfo($orig, PATHINFO_FILENAME);
|
$baseName = pathinfo($orig, PATHINFO_FILENAME);
|
||||||
$destRel = "events/{$r->event_id}/photos/thumbs/{$baseName}_thumb.jpg";
|
$destRel = "events/{$r->event_id}/photos/thumbs/{$baseName}_thumb.jpg";
|
||||||
$made = ImageHelper::makeThumbnailOnDisk('public', $orig, $destRel, 640, 82);
|
$made = ImageHelper::makeThumbnailOnDisk('public', $orig, $destRel, 640, 82);
|
||||||
@@ -44,7 +39,6 @@ class BackfillThumbnails extends Command
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
$this->info("Done. Thumbnails generated: {$count}");
|
$this->info("Done. Thumbnails generated: {$count}");
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +49,6 @@ class BackfillThumbnails extends Command
|
|||||||
if (str_starts_with($p, '/storage/')) {
|
if (str_starts_with($p, '/storage/')) {
|
||||||
return substr($p, strlen('/storage/'));
|
return substr($p, strlen('/storage/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ namespace App\Console\Commands;
|
|||||||
|
|
||||||
use App\Models\PackagePurchase;
|
use App\Models\PackagePurchase;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantPackage;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\TenantPackage;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class MigrateLegacyPurchases extends Command
|
class MigrateLegacyPurchases extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'packages:migrate-legacy';
|
protected $signature = 'packages:migrate-legacy';
|
||||||
|
|
||||||
protected $description = 'Migrate legacy purchases to new system with temp tenants';
|
protected $description = 'Migrate legacy purchases to new system with temp tenants';
|
||||||
|
|
||||||
public function handle()
|
public function handle()
|
||||||
@@ -21,20 +21,19 @@ class MigrateLegacyPurchases extends Command
|
|||||||
|
|
||||||
if ($legacyPurchases->isEmpty()) {
|
if ($legacyPurchases->isEmpty()) {
|
||||||
$this->info('No legacy purchases found.');
|
$this->info('No legacy purchases found.');
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info("Found {$legacyPurchases->count()} legacy purchases.");
|
$this->info("Found {$legacyPurchases->count()} legacy purchases.");
|
||||||
|
|
||||||
foreach ($legacyPurchases as $purchase) {
|
foreach ($legacyPurchases as $purchase) {
|
||||||
if (! $purchase->user_id) {
|
if (!$purchase->user_id) {
|
||||||
// Create temp user if no user
|
// Create temp user if no user
|
||||||
$tempUser = User::create([
|
$tempUser = User::create([
|
||||||
'name' => 'Legacy User '.$purchase->id,
|
'name' => 'Legacy User ' . $purchase->id,
|
||||||
'email' => 'legacy'.$purchase->id.'@fotospiel.local',
|
'email' => 'legacy' . $purchase->id . '@fotospiel.local',
|
||||||
'password' => Hash::make('legacy'),
|
'password' => Hash::make('legacy'),
|
||||||
'username' => 'legacy'.$purchase->id,
|
'username' => 'legacy' . $purchase->id,
|
||||||
'first_name' => 'Legacy',
|
'first_name' => 'Legacy',
|
||||||
'last_name' => 'User',
|
'last_name' => 'User',
|
||||||
'address' => 'Legacy Address',
|
'address' => 'Legacy Address',
|
||||||
@@ -44,7 +43,7 @@ class MigrateLegacyPurchases extends Command
|
|||||||
|
|
||||||
$tempTenant = Tenant::create([
|
$tempTenant = Tenant::create([
|
||||||
'user_id' => $tempUser->id,
|
'user_id' => $tempUser->id,
|
||||||
'name' => 'Legacy Tenant '.$purchase->id,
|
'name' => 'Legacy Tenant ' . $purchase->id,
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -74,7 +73,6 @@ class MigrateLegacyPurchases extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->info('Legacy migration completed.');
|
$this->info('Legacy migration completed.');
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,12 +46,6 @@ class MonitorStorageCommand extends Command
|
|||||||
|
|
||||||
$assetStats = $this->buildAssetStatistics();
|
$assetStats = $this->buildAssetStatistics();
|
||||||
$thresholds = $this->capacityThresholds();
|
$thresholds = $this->capacityThresholds();
|
||||||
$checksumConfig = $this->checksumAlertConfig();
|
|
||||||
$checksumWindowMinutes = $checksumConfig['window_minutes'];
|
|
||||||
$checksumThresholds = $checksumConfig['thresholds'];
|
|
||||||
$checksumMismatches = $checksumConfig['enabled'] && $checksumWindowMinutes > 0
|
|
||||||
? $this->checksumMismatchCounts($checksumWindowMinutes)
|
|
||||||
: [];
|
|
||||||
$alerts = [];
|
$alerts = [];
|
||||||
$snapshotTargets = [];
|
$snapshotTargets = [];
|
||||||
|
|
||||||
@@ -84,7 +78,6 @@ class MonitorStorageCommand extends Command
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$targetChecksumMismatches = $checksumMismatches[$target->id] ?? 0;
|
|
||||||
$snapshotTargets[] = [
|
$snapshotTargets[] = [
|
||||||
'id' => $target->id,
|
'id' => $target->id,
|
||||||
'key' => $target->key,
|
'key' => $target->key,
|
||||||
@@ -92,35 +85,13 @@ class MonitorStorageCommand extends Command
|
|||||||
'is_hot' => (bool) $target->is_hot,
|
'is_hot' => (bool) $target->is_hot,
|
||||||
'capacity' => $capacity,
|
'capacity' => $capacity,
|
||||||
'assets' => $assets,
|
'assets' => $assets,
|
||||||
'checksum_mismatches' => [
|
|
||||||
'count' => $targetChecksumMismatches,
|
|
||||||
'window_minutes' => $checksumWindowMinutes,
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($checksumConfig['enabled'] && $checksumWindowMinutes > 0) {
|
|
||||||
$totalMismatches = array_sum($checksumMismatches);
|
|
||||||
$checksumSeverity = $this->determineChecksumSeverity($totalMismatches, $checksumThresholds);
|
|
||||||
|
|
||||||
if ($checksumSeverity !== 'ok') {
|
|
||||||
$alerts[] = [
|
|
||||||
'type' => 'checksum_mismatch',
|
|
||||||
'severity' => $checksumSeverity,
|
|
||||||
'count' => $totalMismatches,
|
|
||||||
'window_minutes' => $checksumWindowMinutes,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$snapshot = [
|
$snapshot = [
|
||||||
'generated_at' => now()->toIso8601String(),
|
'generated_at' => now()->toIso8601String(),
|
||||||
'targets' => $snapshotTargets,
|
'targets' => $snapshotTargets,
|
||||||
'alerts' => $alerts,
|
'alerts' => $alerts,
|
||||||
'checksum' => [
|
|
||||||
'window_minutes' => $checksumWindowMinutes,
|
|
||||||
'mismatch_total' => array_sum($checksumMismatches),
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$ttlMinutes = max(1, (int) config('storage-monitor.monitor.cache_minutes', 15));
|
$ttlMinutes = max(1, (int) config('storage-monitor.monitor.cache_minutes', 15));
|
||||||
@@ -220,62 +191,4 @@ class MonitorStorageCommand extends Command
|
|||||||
|
|
||||||
return 'ok';
|
return 'ok';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function checksumAlertConfig(): array
|
|
||||||
{
|
|
||||||
$enabled = (bool) config('storage-monitor.checksum_validation.enabled', true);
|
|
||||||
$windowMinutes = max(0, (int) config('storage-monitor.checksum_validation.alert_window_minutes', 60));
|
|
||||||
$warning = (int) config('storage-monitor.checksum_validation.thresholds.warning', 1);
|
|
||||||
$critical = (int) config('storage-monitor.checksum_validation.thresholds.critical', 5);
|
|
||||||
|
|
||||||
if ($warning > $critical && $critical > 0) {
|
|
||||||
[$warning, $critical] = [$critical, $warning];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'enabled' => $enabled,
|
|
||||||
'window_minutes' => $windowMinutes,
|
|
||||||
'thresholds' => [
|
|
||||||
'warning' => $warning,
|
|
||||||
'critical' => $critical,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function checksumMismatchCounts(int $windowMinutes): array
|
|
||||||
{
|
|
||||||
$query = EventMediaAsset::query()
|
|
||||||
->selectRaw('media_storage_target_id, COUNT(*) as total_count')
|
|
||||||
->where('status', 'failed')
|
|
||||||
->where('meta->checksum_status', 'mismatch');
|
|
||||||
|
|
||||||
if ($windowMinutes > 0) {
|
|
||||||
$query->where('updated_at', '>=', now()->subMinutes($windowMinutes));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query->groupBy('media_storage_target_id')
|
|
||||||
->get()
|
|
||||||
->mapWithKeys(fn ($row) => [(int) $row->media_storage_target_id => (int) $row->total_count])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function determineChecksumSeverity(int $count, array $thresholds): string
|
|
||||||
{
|
|
||||||
$warning = (int) ($thresholds['warning'] ?? 1);
|
|
||||||
$critical = (int) ($thresholds['critical'] ?? 5);
|
|
||||||
|
|
||||||
if ($count <= 0) {
|
|
||||||
return 'ok';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($critical > 0 && $count >= $critical) {
|
|
||||||
return 'critical';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($warning > 0 && $count >= $warning) {
|
|
||||||
return 'warning';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'ok';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
{
|
{
|
||||||
protected $signature = 'demo:seed-switcher {--with-photos : Download sample photos from Pexels} {--photos-per-event=18 : Target photos per event when downloading} {--cleanup : Remove demo switcher tenants/events/photos instead of seeding}';
|
protected $signature = 'demo:seed-switcher {--with-photos : Download sample photos from Pexels} {--photos-per-event=18 : Target photos per event when downloading} {--cleanup : Remove demo switcher tenants/events/photos instead of seeding}';
|
||||||
|
|
||||||
protected $description = 'Seeds demo tenants used by the DevTenantSwitcher (endcustomer + partner profiles)';
|
protected $description = 'Seeds demo tenants used by the DevTenantSwitcher (endcustomer + reseller profiles)';
|
||||||
|
|
||||||
public function __construct(private EventStorageManager $eventStorageManager)
|
public function __construct(private EventStorageManager $eventStorageManager)
|
||||||
{
|
{
|
||||||
@@ -52,7 +52,7 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
|
|
||||||
DB::transaction(function () use ($packages, $eventTypes) {
|
DB::transaction(function () use ($packages, $eventTypes) {
|
||||||
$this->seedCustomerStandardEmpty($packages, $eventTypes);
|
$this->seedCustomerStandardEmpty($packages, $eventTypes);
|
||||||
$this->seedCustomerStandardWedding($packages, $eventTypes);
|
$this->seedCustomerStarterWedding($packages, $eventTypes);
|
||||||
$this->seedResellerActive($packages, $eventTypes);
|
$this->seedResellerActive($packages, $eventTypes);
|
||||||
$this->seedResellerFull($packages, $eventTypes);
|
$this->seedResellerFull($packages, $eventTypes);
|
||||||
});
|
});
|
||||||
@@ -129,7 +129,7 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
$slugs = [
|
$slugs = [
|
||||||
'starter' => 'Starter',
|
'starter' => 'Starter',
|
||||||
'standard' => 'Standard',
|
'standard' => 'Standard',
|
||||||
's-small-reseller' => 'Partner Start',
|
's-small-reseller' => 'Reseller S',
|
||||||
];
|
];
|
||||||
|
|
||||||
$packages = [];
|
$packages = [];
|
||||||
@@ -165,10 +165,10 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
{
|
{
|
||||||
$tenant = $this->upsertTenant(
|
$tenant = $this->upsertTenant(
|
||||||
slug: 'demo-standard-empty',
|
slug: 'demo-standard-empty',
|
||||||
name: 'Demo Starter (ohne Event)',
|
name: 'Demo Standard (ohne Event)',
|
||||||
contactEmail: 'standard-empty@demo.fotospiel',
|
contactEmail: 'standard-empty@demo.fotospiel',
|
||||||
attributes: [
|
attributes: [
|
||||||
'subscription_tier' => 'starter',
|
'subscription_tier' => 'standard',
|
||||||
'subscription_status' => 'active',
|
'subscription_status' => 'active',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -176,9 +176,9 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
$this->upsertAdmin($tenant, 'standard-empty@demo.fotospiel');
|
$this->upsertAdmin($tenant, 'standard-empty@demo.fotospiel');
|
||||||
|
|
||||||
TenantPackage::updateOrCreate(
|
TenantPackage::updateOrCreate(
|
||||||
['tenant_id' => $tenant->id, 'package_id' => $packages['starter']->id],
|
['tenant_id' => $tenant->id, 'package_id' => $packages['standard']->id],
|
||||||
[
|
[
|
||||||
'price' => $packages['starter']->price,
|
'price' => $packages['standard']->price,
|
||||||
'purchased_at' => Carbon::now()->subDays(1),
|
'purchased_at' => Carbon::now()->subDays(1),
|
||||||
'expires_at' => Carbon::now()->addMonths(12),
|
'expires_at' => Carbon::now()->addMonths(12),
|
||||||
'used_events' => 0,
|
'used_events' => 0,
|
||||||
@@ -186,17 +186,17 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->comment('Seeded Starter tenant without events.');
|
$this->comment('Seeded Standard tenant without events.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function seedCustomerStandardWedding(array $packages, array $eventTypes): void
|
private function seedCustomerStarterWedding(array $packages, array $eventTypes): void
|
||||||
{
|
{
|
||||||
$tenant = $this->upsertTenant(
|
$tenant = $this->upsertTenant(
|
||||||
slug: 'demo-starter-wedding',
|
slug: 'demo-starter-wedding',
|
||||||
name: 'Demo Standard Wedding',
|
name: 'Demo Starter Wedding',
|
||||||
contactEmail: 'starter-wedding@demo.fotospiel',
|
contactEmail: 'starter-wedding@demo.fotospiel',
|
||||||
attributes: [
|
attributes: [
|
||||||
'subscription_tier' => 'standard',
|
'subscription_tier' => 'starter',
|
||||||
'subscription_status' => 'active',
|
'subscription_status' => 'active',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -209,7 +209,7 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
'price' => $packages['standard']->price,
|
'price' => $packages['standard']->price,
|
||||||
'purchased_at' => Carbon::now()->subDays(1),
|
'purchased_at' => Carbon::now()->subDays(1),
|
||||||
'expires_at' => Carbon::now()->addMonths(12),
|
'expires_at' => Carbon::now()->addMonths(12),
|
||||||
'used_events' => 1,
|
'used_events' => 0,
|
||||||
'active' => true,
|
'active' => true,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -232,18 +232,17 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
|
|
||||||
private function seedResellerActive(array $packages, array $eventTypes): void
|
private function seedResellerActive(array $packages, array $eventTypes): void
|
||||||
{
|
{
|
||||||
$eventPackage = $this->resolveIncludedPackage($packages['s-small-reseller'], $packages);
|
|
||||||
$tenant = $this->upsertTenant(
|
$tenant = $this->upsertTenant(
|
||||||
slug: 'demo-reseller-active',
|
slug: 'demo-reseller-active',
|
||||||
name: 'Demo Partner Active',
|
name: 'Demo Reseller Active',
|
||||||
contactEmail: 'partner-active@demo.fotospiel',
|
contactEmail: 'reseller-active@demo.fotospiel',
|
||||||
attributes: [
|
attributes: [
|
||||||
'subscription_tier' => 'reseller',
|
'subscription_tier' => 'reseller',
|
||||||
'subscription_status' => 'active',
|
'subscription_status' => 'active',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->upsertAdmin($tenant, 'partner-active@demo.fotospiel');
|
$this->upsertAdmin($tenant, 'reseller-active@demo.fotospiel');
|
||||||
|
|
||||||
TenantPackage::updateOrCreate(
|
TenantPackage::updateOrCreate(
|
||||||
['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id],
|
['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id],
|
||||||
@@ -280,7 +279,7 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
foreach ($events as $index => $config) {
|
foreach ($events as $index => $config) {
|
||||||
$event = $this->upsertEvent(
|
$event = $this->upsertEvent(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
package: $eventPackage,
|
package: $packages['standard'],
|
||||||
eventType: $config['type'],
|
eventType: $config['type'],
|
||||||
attributes: [
|
attributes: [
|
||||||
'name' => $config['name'],
|
'name' => $config['name'],
|
||||||
@@ -297,18 +296,17 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
|
|
||||||
private function seedResellerFull(array $packages, array $eventTypes): void
|
private function seedResellerFull(array $packages, array $eventTypes): void
|
||||||
{
|
{
|
||||||
$eventPackage = $this->resolveIncludedPackage($packages['s-small-reseller'], $packages);
|
|
||||||
$tenant = $this->upsertTenant(
|
$tenant = $this->upsertTenant(
|
||||||
slug: 'demo-reseller-full',
|
slug: 'demo-reseller-full',
|
||||||
name: 'Demo Partner Voll',
|
name: 'Demo Reseller Voll',
|
||||||
contactEmail: 'partner-full@demo.fotospiel',
|
contactEmail: 'reseller-full@demo.fotospiel',
|
||||||
attributes: [
|
attributes: [
|
||||||
'subscription_tier' => 'reseller',
|
'subscription_tier' => 'reseller',
|
||||||
'subscription_status' => 'active',
|
'subscription_status' => 'active',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->upsertAdmin($tenant, 'partner-full@demo.fotospiel');
|
$this->upsertAdmin($tenant, 'reseller-full@demo.fotospiel');
|
||||||
|
|
||||||
TenantPackage::updateOrCreate(
|
TenantPackage::updateOrCreate(
|
||||||
['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id],
|
['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id],
|
||||||
@@ -332,7 +330,7 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
foreach ($eventConfigs as $index => $config) {
|
foreach ($eventConfigs as $index => $config) {
|
||||||
$event = $this->upsertEvent(
|
$event = $this->upsertEvent(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
package: $eventPackage,
|
package: $packages['standard'],
|
||||||
eventType: $config['type'],
|
eventType: $config['type'],
|
||||||
attributes: [
|
attributes: [
|
||||||
'name' => $config['name'],
|
'name' => $config['name'],
|
||||||
@@ -359,8 +357,8 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
'settings' => [
|
'settings' => [
|
||||||
'branding' => [
|
'branding' => [
|
||||||
'logo_url' => null,
|
'logo_url' => null,
|
||||||
'primary_color' => '#FF5A5F',
|
'primary_color' => '#1D4ED8',
|
||||||
'secondary_color' => '#FFF8F5',
|
'secondary_color' => '#0F172A',
|
||||||
'font_family' => 'Inter, sans-serif',
|
'font_family' => 'Inter, sans-serif',
|
||||||
],
|
],
|
||||||
'features' => [
|
'features' => [
|
||||||
@@ -437,19 +435,6 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
return $event;
|
return $event;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveIncludedPackage(Package $resellerPackage, array $packages): Package
|
|
||||||
{
|
|
||||||
$includedSlug = $resellerPackage->included_package_slug;
|
|
||||||
|
|
||||||
if ($includedSlug && isset($packages[$includedSlug])) {
|
|
||||||
return $packages[$includedSlug];
|
|
||||||
}
|
|
||||||
|
|
||||||
$fallback = $packages['starter'] ?? $packages['standard'] ?? null;
|
|
||||||
|
|
||||||
return $fallback ?? $resellerPackage;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function fallbackEventType(): ?EventType
|
private function fallbackEventType(): ?EventType
|
||||||
{
|
{
|
||||||
$fallback = EventType::first();
|
$fallback = EventType::first();
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class SendAbandonedCheckoutReminders extends Command
|
|||||||
if ($this->shouldSendReminder($checkout, $stage)) {
|
if ($this->shouldSendReminder($checkout, $stage)) {
|
||||||
$resumeUrl = $this->generateResumeUrl($checkout);
|
$resumeUrl = $this->generateResumeUrl($checkout);
|
||||||
|
|
||||||
if (! $isDryRun) {
|
if (!$isDryRun) {
|
||||||
$mailLocale = $checkout->user->preferred_locale ?? config('app.locale');
|
$mailLocale = $checkout->user->preferred_locale ?? config('app.locale');
|
||||||
|
|
||||||
Mail::to($checkout->user)
|
Mail::to($checkout->user)
|
||||||
@@ -86,8 +86,8 @@ class SendAbandonedCheckoutReminders extends Command
|
|||||||
$totalProcessed++;
|
$totalProcessed++;
|
||||||
}
|
}
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
Log::error("Failed to send {$stage} reminder for checkout {$checkout->id}: ".$e->getMessage());
|
Log::error("Failed to send {$stage} reminder for checkout {$checkout->id}: " . $e->getMessage());
|
||||||
$this->error(" ❌ Failed to process checkout {$checkout->id}: ".$e->getMessage());
|
$this->error(" ❌ Failed to process checkout {$checkout->id}: " . $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,7 +98,7 @@ class SendAbandonedCheckoutReminders extends Command
|
|||||||
->count();
|
->count();
|
||||||
|
|
||||||
if ($oldCheckouts > 0) {
|
if ($oldCheckouts > 0) {
|
||||||
if (! $isDryRun) {
|
if (!$isDryRun) {
|
||||||
AbandonedCheckoutModel::where('abandoned_at', '<', now()->subDays(30))
|
AbandonedCheckoutModel::where('abandoned_at', '<', now()->subDays(30))
|
||||||
->where('converted', false)
|
->where('converted', false)
|
||||||
->delete();
|
->delete();
|
||||||
@@ -108,10 +108,10 @@ class SendAbandonedCheckoutReminders extends Command
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info('✅ Reminder process completed!');
|
$this->info("✅ Reminder process completed!");
|
||||||
$this->info(" Processed: {$totalProcessed} checkouts");
|
$this->info(" Processed: {$totalProcessed} checkouts");
|
||||||
|
|
||||||
if (! $isDryRun) {
|
if (!$isDryRun) {
|
||||||
$this->info(" Sent: {$totalSent} reminder emails");
|
$this->info(" Sent: {$totalSent} reminder emails");
|
||||||
} else {
|
} else {
|
||||||
$this->info(" Would send: {$totalSent} reminder emails");
|
$this->info(" Would send: {$totalSent} reminder emails");
|
||||||
@@ -131,12 +131,12 @@ class SendAbandonedCheckoutReminders extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
// User existiert noch?
|
// User existiert noch?
|
||||||
if (! $checkout->user) {
|
if (!$checkout->user) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Package existiert noch?
|
// Package existiert noch?
|
||||||
if (! $checkout->package) {
|
if (!$checkout->package) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use Illuminate\Support\Str;
|
|||||||
|
|
||||||
class SyncGoogleFonts extends Command
|
class SyncGoogleFonts extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'fonts:sync-google {--count=50 : Number of popular fonts to fetch} {--weights=400,700 : Comma separated numeric font weights to download} {--italic : Also download italic variants where available} {--force : Re-download files even if they exist} {--path= : Optional custom output directory (defaults to public/fonts/google)} {--family= : Download specific family name(s), comma separated (case-insensitive)} {--category= : Filter by category, comma separated (e.g. sans-serif,serif)} {--prune : Remove local font families not included in this sync} {--dry-run : Show what would be downloaded without writing files} {--from-disk : Rebuild manifest + CSS from existing font files without downloading}';
|
protected $signature = 'fonts:sync-google {--count=50 : Number of popular fonts to fetch} {--weights=400,700 : Comma separated numeric font weights to download} {--italic : Also download italic variants where available} {--force : Re-download files even if they exist} {--path= : Optional custom output directory (defaults to public/fonts/google)} {--family= : Download specific family name(s), comma separated (case-insensitive)} {--category= : Filter by category, comma separated (e.g. sans-serif,serif)} {--prune : Remove local font families not included in this sync} {--dry-run : Show what would be downloaded without writing files}';
|
||||||
|
|
||||||
protected $description = 'Download the most popular Google Fonts to the local public/fonts/google directory and generate a manifest + CSS file.';
|
protected $description = 'Download the most popular Google Fonts to the local public/fonts/google directory and generate a manifest + CSS file.';
|
||||||
|
|
||||||
@@ -20,17 +20,6 @@ class SyncGoogleFonts extends Command
|
|||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
$dryRun = (bool) $this->option('dry-run');
|
|
||||||
$fromDisk = (bool) $this->option('from-disk');
|
|
||||||
$pathOption = $this->option('path');
|
|
||||||
$basePath = $pathOption
|
|
||||||
? (Str::startsWith($pathOption, DIRECTORY_SEPARATOR) ? $pathOption : base_path($pathOption))
|
|
||||||
: public_path('fonts/google');
|
|
||||||
|
|
||||||
if ($fromDisk) {
|
|
||||||
return $this->syncFromDisk($basePath, $dryRun);
|
|
||||||
}
|
|
||||||
|
|
||||||
$apiKey = config('services.google_fonts.key');
|
$apiKey = config('services.google_fonts.key');
|
||||||
|
|
||||||
if (! $apiKey) {
|
if (! $apiKey) {
|
||||||
@@ -43,10 +32,16 @@ class SyncGoogleFonts extends Command
|
|||||||
$weights = $this->prepareWeights($this->option('weights'));
|
$weights = $this->prepareWeights($this->option('weights'));
|
||||||
$includeItalic = (bool) $this->option('italic');
|
$includeItalic = (bool) $this->option('italic');
|
||||||
$force = (bool) $this->option('force');
|
$force = (bool) $this->option('force');
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
$families = $this->normalizeFamilyOption($this->option('family'));
|
$families = $this->normalizeFamilyOption($this->option('family'));
|
||||||
$categories = $this->prepareCategories($this->option('category'));
|
$categories = $this->prepareCategories($this->option('category'));
|
||||||
$prune = (bool) $this->option('prune');
|
$prune = (bool) $this->option('prune');
|
||||||
|
|
||||||
|
$pathOption = $this->option('path');
|
||||||
|
$basePath = $pathOption
|
||||||
|
? (Str::startsWith($pathOption, DIRECTORY_SEPARATOR) ? $pathOption : base_path($pathOption))
|
||||||
|
: public_path('fonts/google');
|
||||||
|
|
||||||
if (count($families)) {
|
if (count($families)) {
|
||||||
$label = count($families) > 1 ? 'families' : 'family';
|
$label = count($families) > 1 ? 'families' : 'family';
|
||||||
$this->info(sprintf('Fetching Google Font %s "%s" (weights: %s, italic: %s)...', $label, implode(', ', $families), implode(', ', $weights), $includeItalic ? 'yes' : 'no'));
|
$this->info(sprintf('Fetching Google Font %s "%s" (weights: %s, italic: %s)...', $label, implode(', ', $families), implode(', ', $weights), $includeItalic ? 'yes' : 'no'));
|
||||||
@@ -211,204 +206,6 @@ class SyncGoogleFonts extends Command
|
|||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function syncFromDisk(string $basePath, bool $dryRun): int
|
|
||||||
{
|
|
||||||
if (! File::isDirectory($basePath)) {
|
|
||||||
$this->error(sprintf('Font directory not found: %s', $basePath));
|
|
||||||
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->option('prune')) {
|
|
||||||
$this->warn('Ignoring --prune when rebuilding from disk.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$fonts = $this->buildManifestFromDisk($basePath);
|
|
||||||
|
|
||||||
if (! count($fonts)) {
|
|
||||||
$this->warn('No fonts found on disk.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($dryRun) {
|
|
||||||
$this->info(sprintf('Dry run complete: %d font families would be written to %s', count($fonts), $basePath));
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->writeManifest($basePath, $fonts);
|
|
||||||
$this->writeCss($basePath, $fonts);
|
|
||||||
Cache::forget('fonts:manifest');
|
|
||||||
|
|
||||||
$this->info(sprintf('Rebuilt manifest for %d font families from %s', count($fonts), $basePath));
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private function buildManifestFromDisk(string $basePath): array
|
|
||||||
{
|
|
||||||
$directories = File::directories($basePath);
|
|
||||||
$fonts = [];
|
|
||||||
|
|
||||||
foreach ($directories as $dir) {
|
|
||||||
$slug = basename($dir);
|
|
||||||
$files = collect(File::files($dir))
|
|
||||||
->filter(function (\SplFileInfo $file) {
|
|
||||||
$extension = strtolower($file->getExtension());
|
|
||||||
|
|
||||||
return in_array($extension, ['woff2', 'woff', 'otf', 'ttf'], true);
|
|
||||||
})
|
|
||||||
->values();
|
|
||||||
|
|
||||||
if (! $files->count()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$variantsByKey = [];
|
|
||||||
foreach ($files as $file) {
|
|
||||||
$filename = $file->getFilename();
|
|
||||||
$extension = strtolower($file->getExtension());
|
|
||||||
$style = $this->extractStyleFromFilename($filename);
|
|
||||||
$weight = $this->extractWeightFromFilename($filename);
|
|
||||||
$variantKey = $this->buildVariantKey($weight, $style);
|
|
||||||
$priority = $this->extensionPriority($extension);
|
|
||||||
$relativePath = sprintf('/fonts/google/%s/%s', $slug, $filename);
|
|
||||||
|
|
||||||
$existing = $variantsByKey[$variantKey] ?? null;
|
|
||||||
if ($existing && ($existing['priority'] ?? 0) >= $priority) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$variantsByKey[$variantKey] = [
|
|
||||||
'variant' => $variantKey,
|
|
||||||
'weight' => $weight,
|
|
||||||
'style' => $style,
|
|
||||||
'url' => $relativePath,
|
|
||||||
'priority' => $priority,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! count($variantsByKey)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$variants = array_values(array_map(function (array $variant) {
|
|
||||||
unset($variant['priority']);
|
|
||||||
|
|
||||||
return $variant;
|
|
||||||
}, $variantsByKey));
|
|
||||||
|
|
||||||
usort($variants, function (array $left, array $right) {
|
|
||||||
$weightCompare = ($left['weight'] ?? 400) <=> ($right['weight'] ?? 400);
|
|
||||||
if ($weightCompare !== 0) {
|
|
||||||
return $weightCompare;
|
|
||||||
}
|
|
||||||
|
|
||||||
return strcmp((string) ($left['style'] ?? 'normal'), (string) ($right['style'] ?? 'normal'));
|
|
||||||
});
|
|
||||||
|
|
||||||
$fonts[] = [
|
|
||||||
'family' => $this->familyFromSlug($slug),
|
|
||||||
'slug' => $slug,
|
|
||||||
'category' => null,
|
|
||||||
'variants' => $variants,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
usort($fonts, fn (array $left, array $right) => strcmp((string) $left['family'], (string) $right['family']));
|
|
||||||
|
|
||||||
return $fonts;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function familyFromSlug(string $slug): string
|
|
||||||
{
|
|
||||||
$parts = array_filter(explode('-', $slug), fn ($part) => $part !== '');
|
|
||||||
|
|
||||||
$words = array_map(function (string $part) {
|
|
||||||
if (is_numeric($part)) {
|
|
||||||
return $part;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strlen($part) <= 3) {
|
|
||||||
return strtoupper($part);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ucfirst(strtolower($part));
|
|
||||||
}, $parts);
|
|
||||||
|
|
||||||
return trim(implode(' ', $words));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function extractStyleFromFilename(string $filename): string
|
|
||||||
{
|
|
||||||
$lower = strtolower($filename);
|
|
||||||
|
|
||||||
return str_contains($lower, 'italic') || str_contains($lower, 'oblique') ? 'italic' : 'normal';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function extractWeightFromFilename(string $filename): int
|
|
||||||
{
|
|
||||||
if (preg_match('/(?:^|[^0-9])(100|200|300|400|500|600|700|800|900)(?:[^0-9]|$)/', $filename, $matches)) {
|
|
||||||
return (int) $matches[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
$lower = strtolower($filename);
|
|
||||||
$weightMap = [
|
|
||||||
'thin' => 100,
|
|
||||||
'extralight' => 200,
|
|
||||||
'ultralight' => 200,
|
|
||||||
'light' => 300,
|
|
||||||
'regular' => 400,
|
|
||||||
'book' => 400,
|
|
||||||
'medium' => 500,
|
|
||||||
'semibold' => 600,
|
|
||||||
'demibold' => 600,
|
|
||||||
'bold' => 700,
|
|
||||||
'extrabold' => 800,
|
|
||||||
'ultrabold' => 800,
|
|
||||||
'black' => 900,
|
|
||||||
'heavy' => 900,
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($weightMap as $label => $weight) {
|
|
||||||
if (str_contains($lower, $label)) {
|
|
||||||
return $weight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildVariantKey(int $weight, string $style): string
|
|
||||||
{
|
|
||||||
if ($weight === 400 && $style === 'normal') {
|
|
||||||
return 'regular';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($weight === 400 && $style === 'italic') {
|
|
||||||
return 'italic';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($style === 'italic') {
|
|
||||||
return $weight.'italic';
|
|
||||||
}
|
|
||||||
|
|
||||||
return (string) $weight;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function extensionPriority(string $extension): int
|
|
||||||
{
|
|
||||||
return match ($extension) {
|
|
||||||
'woff2' => 4,
|
|
||||||
'woff' => 3,
|
|
||||||
'otf' => 2,
|
|
||||||
'ttf' => 1,
|
|
||||||
default => 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,4 +6,4 @@ enum PackageType: string
|
|||||||
{
|
{
|
||||||
case ENDCUSTOMER = 'endcustomer';
|
case ENDCUSTOMER = 'endcustomer';
|
||||||
case RESELLER = 'reseller';
|
case RESELLER = 'reseller';
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,8 @@ namespace App\Exports;
|
|||||||
use App\Models\EventPurchase;
|
use App\Models\EventPurchase;
|
||||||
use Filament\Actions\Exports\Exporter;
|
use Filament\Actions\Exports\Exporter;
|
||||||
use Filament\Actions\Exports\Models\Export;
|
use Filament\Actions\Exports\Models\Export;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class EventPurchaseExporter extends Exporter
|
class EventPurchaseExporter extends Exporter
|
||||||
{
|
{
|
||||||
@@ -26,10 +28,11 @@ class EventPurchaseExporter extends Exporter
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public static function getCompletedNotificationBody(Export $export): string
|
public static function getCompletedNotificationBody(Export $export): string
|
||||||
{
|
{
|
||||||
$body = "Your Event Purchases export has completed and is ready for download. {$export->successful_rows} purchases were exported.";
|
$body = "Your Event Purchases export has completed and is ready for download. {$export->successful_rows} purchases were exported.";
|
||||||
|
|
||||||
return $body;
|
return $body;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,4 +16,4 @@ class ListCategories extends ListRecords
|
|||||||
Actions\CreateAction::make(),
|
Actions\CreateAction::make(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,10 +79,9 @@ class PostResource extends Resource
|
|||||||
->label('Inhalt')
|
->label('Inhalt')
|
||||||
->required()
|
->required()
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Textarea::make('excerpt.de')
|
TextInput::make('excerpt.de')
|
||||||
->label('Auszug')
|
->label('Auszug')
|
||||||
->maxLength(65535)
|
->maxLength(255),
|
||||||
->columnSpanFull(),
|
|
||||||
TextInput::make('meta_title.de')
|
TextInput::make('meta_title.de')
|
||||||
->label('Meta-Titel')
|
->label('Meta-Titel')
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
@@ -100,10 +99,9 @@ class PostResource extends Resource
|
|||||||
MarkdownEditor::make('content.en')
|
MarkdownEditor::make('content.en')
|
||||||
->label('Inhalt')
|
->label('Inhalt')
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Textarea::make('excerpt.en')
|
TextInput::make('excerpt.en')
|
||||||
->label('Auszug')
|
->label('Auszug')
|
||||||
->maxLength(65535)
|
->maxLength(255),
|
||||||
->columnSpanFull(),
|
|
||||||
TextInput::make('meta_title.en')
|
TextInput::make('meta_title.en')
|
||||||
->label('Meta-Titel')
|
->label('Meta-Titel')
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
@@ -123,10 +121,9 @@ class PostResource extends Resource
|
|||||||
->unique(BlogPost::class, 'slug', ignoreRecord: true)
|
->unique(BlogPost::class, 'slug', ignoreRecord: true)
|
||||||
->maxLength(255)
|
->maxLength(255)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
FileUpload::make('banner')
|
FileUpload::make('featured_image')
|
||||||
->label('Featured Image')
|
->label('Featured Image')
|
||||||
->image()
|
->image()
|
||||||
->disk('public')
|
|
||||||
->directory('blog')
|
->directory('blog')
|
||||||
->visibility('public'),
|
->visibility('public'),
|
||||||
Select::make('blog_category_id')
|
Select::make('blog_category_id')
|
||||||
|
|||||||
@@ -16,4 +16,4 @@ class ListPosts extends ListRecords
|
|||||||
Actions\CreateAction::make(),
|
Actions\CreateAction::make(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,4 +8,4 @@ use Filament\Resources\Pages\ViewRecord;
|
|||||||
class ViewPost extends ViewRecord
|
class ViewPost extends ViewRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = PostResource::class;
|
protected static string $resource = PostResource::class;
|
||||||
}
|
}
|
||||||
@@ -26,4 +26,4 @@ trait HasContentEditor
|
|||||||
'h3',
|
'h3',
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\TaskCollectionResource;
|
|
||||||
use App\Filament\Resources\Pages\AuditedCreateRecord;
|
|
||||||
|
|
||||||
class CreateTaskCollection extends AuditedCreateRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = TaskCollectionResource::class;
|
|
||||||
|
|
||||||
protected function mutateFormDataBeforeCreate(array $data): array
|
|
||||||
{
|
|
||||||
return TaskCollectionResource::normalizeData($data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\TaskCollectionResource;
|
|
||||||
use App\Filament\Resources\Pages\AuditedEditRecord;
|
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
|
||||||
use Filament\Actions;
|
|
||||||
|
|
||||||
class EditTaskCollection extends AuditedEditRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = TaskCollectionResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Actions\DeleteAction::make()
|
|
||||||
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
|
||||||
'deleted',
|
|
||||||
$record,
|
|
||||||
source: static::class
|
|
||||||
)),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function mutateFormDataBeforeSave(array $data): array
|
|
||||||
{
|
|
||||||
return TaskCollectionResource::normalizeData($data, $this->record);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\TaskCollectionResource;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListTaskCollections extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = TaskCollectionResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Actions\CreateAction::make(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\RelationManagers;
|
|
||||||
|
|
||||||
use App\Models\Task;
|
|
||||||
use Filament\Actions\AttachAction;
|
|
||||||
use Filament\Actions\BulkActionGroup;
|
|
||||||
use Filament\Actions\DetachAction;
|
|
||||||
use Filament\Actions\DetachBulkAction;
|
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
|
||||||
use Filament\Tables\Columns\IconColumn;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
|
|
||||||
class TasksRelationManager extends RelationManager
|
|
||||||
{
|
|
||||||
protected static string $relationship = 'tasks';
|
|
||||||
|
|
||||||
protected static ?string $inverseRelationship = 'taskCollections';
|
|
||||||
|
|
||||||
public function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->columns([
|
|
||||||
TextColumn::make('title')
|
|
||||||
->label(__('admin.tasks.table.title'))
|
|
||||||
->getStateUsing(fn (Task $record) => $this->formatTaskTitle($record->title))
|
|
||||||
->searchable(['title->de', 'title->en'])
|
|
||||||
->limit(60),
|
|
||||||
TextColumn::make('emotion.name')
|
|
||||||
->label(__('admin.tasks.fields.emotion'))
|
|
||||||
->getStateUsing(function (Task $record) {
|
|
||||||
$value = optional($record->emotion)->name;
|
|
||||||
if (is_array($value)) {
|
|
||||||
$locale = app()->getLocale();
|
|
||||||
|
|
||||||
return $value[$locale] ?? ($value['de'] ?? ($value['en'] ?? ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (string) ($value ?? '');
|
|
||||||
})
|
|
||||||
->sortable(),
|
|
||||||
TextColumn::make('difficulty')
|
|
||||||
->label(__('admin.tasks.fields.difficulty.label'))
|
|
||||||
->badge(),
|
|
||||||
IconColumn::make('is_active')
|
|
||||||
->label(__('admin.tasks.table.is_active'))
|
|
||||||
->boolean(),
|
|
||||||
TextColumn::make('sort_order')
|
|
||||||
->label(__('admin.tasks.table.sort_order'))
|
|
||||||
->sortable(),
|
|
||||||
])
|
|
||||||
->headerActions([
|
|
||||||
AttachAction::make()
|
|
||||||
->recordTitle(fn (Task $record) => $this->formatTaskTitle($record->title))
|
|
||||||
->recordSelectOptionsQuery(fn (Builder $query): Builder => $query->whereNull('tenant_id'))
|
|
||||||
->multiple()
|
|
||||||
->after(function (array $data): void {
|
|
||||||
$collection = $this->getOwnerRecord();
|
|
||||||
$recordIds = Arr::wrap($data['recordId'] ?? []);
|
|
||||||
|
|
||||||
if ($recordIds === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$collection->reassignTasks($recordIds);
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->recordActions([
|
|
||||||
DetachAction::make()
|
|
||||||
->after(function (?Task $record): void {
|
|
||||||
if (! $record) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$collectionId = $this->getOwnerRecord()->getKey();
|
|
||||||
|
|
||||||
if ($record->collection_id === $collectionId) {
|
|
||||||
$record->update(['collection_id' => null]);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->toolbarActions([
|
|
||||||
BulkActionGroup::make([
|
|
||||||
DetachBulkAction::make()
|
|
||||||
->after(function (Collection $records): void {
|
|
||||||
$collectionId = $this->getOwnerRecord()->getKey();
|
|
||||||
|
|
||||||
$ids = $records
|
|
||||||
->filter(fn (Task $record) => $record->collection_id === $collectionId)
|
|
||||||
->pluck('id')
|
|
||||||
->all();
|
|
||||||
|
|
||||||
if ($ids === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Task::query()
|
|
||||||
->whereIn('id', $ids)
|
|
||||||
->update(['collection_id' => null]);
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, string>|string|null $value
|
|
||||||
*/
|
|
||||||
protected function formatTaskTitle(array|string|null $value): string
|
|
||||||
{
|
|
||||||
if (is_array($value)) {
|
|
||||||
$locale = app()->getLocale();
|
|
||||||
|
|
||||||
return $value[$locale]
|
|
||||||
?? ($value['de'] ?? ($value['en'] ?? Arr::first($value) ?? ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($value)) {
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Clusters\WeeklyOps\Resources\TaskCollections;
|
|
||||||
|
|
||||||
use App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\Pages\CreateTaskCollection;
|
|
||||||
use App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\Pages\EditTaskCollection;
|
|
||||||
use App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\Pages\ListTaskCollections;
|
|
||||||
use App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\RelationManagers\TasksRelationManager;
|
|
||||||
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
|
|
||||||
use App\Models\EventType;
|
|
||||||
use App\Models\TaskCollection;
|
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Forms\Components\MarkdownEditor;
|
|
||||||
use Filament\Forms\Components\Select;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Forms\Components\Toggle;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Schemas\Components\Tabs as SchemaTabs;
|
|
||||||
use Filament\Schemas\Components\Tabs\Tab as SchemaTab;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Tables\Columns\IconColumn;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class TaskCollectionResource extends Resource
|
|
||||||
{
|
|
||||||
protected static ?string $model = TaskCollection::class;
|
|
||||||
|
|
||||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-rectangle-stack';
|
|
||||||
|
|
||||||
protected static ?string $cluster = WeeklyOpsCluster::class;
|
|
||||||
|
|
||||||
protected static ?string $recordTitleAttribute = 'name';
|
|
||||||
|
|
||||||
protected static ?int $navigationSort = 31;
|
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema
|
|
||||||
->schema([
|
|
||||||
TextInput::make('slug')
|
|
||||||
->label(__('admin.common.slug'))
|
|
||||||
->maxLength(255)
|
|
||||||
->unique(ignoreRecord: true)
|
|
||||||
->required(),
|
|
||||||
Select::make('event_type_id')
|
|
||||||
->relationship('eventType', 'name')
|
|
||||||
->getOptionLabelFromRecordUsing(fn (EventType $record) => is_array($record->name) ? ($record->name['de'] ?? $record->name['en'] ?? __('admin.common.unnamed')) : $record->name)
|
|
||||||
->searchable()
|
|
||||||
->preload()
|
|
||||||
->label(__('admin.task_collections.fields.event_type_optional')),
|
|
||||||
SchemaTabs::make('content_tabs')
|
|
||||||
->label(__('admin.task_collections.fields.content_localization'))
|
|
||||||
->tabs([
|
|
||||||
SchemaTab::make(__('admin.common.german'))
|
|
||||||
->icon('heroicon-o-language')
|
|
||||||
->schema([
|
|
||||||
TextInput::make('name_translations.de')
|
|
||||||
->label(__('admin.task_collections.fields.name_de'))
|
|
||||||
->required(),
|
|
||||||
MarkdownEditor::make('description_translations.de')
|
|
||||||
->label(__('admin.task_collections.fields.description_de'))
|
|
||||||
->columnSpanFull(),
|
|
||||||
]),
|
|
||||||
SchemaTab::make(__('admin.common.english'))
|
|
||||||
->icon('heroicon-o-language')
|
|
||||||
->schema([
|
|
||||||
TextInput::make('name_translations.en')
|
|
||||||
->label(__('admin.task_collections.fields.name_en'))
|
|
||||||
->required(),
|
|
||||||
MarkdownEditor::make('description_translations.en')
|
|
||||||
->label(__('admin.task_collections.fields.description_en'))
|
|
||||||
->columnSpanFull(),
|
|
||||||
]),
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
|
||||||
Toggle::make('is_default')
|
|
||||||
->label(__('admin.task_collections.fields.is_default'))
|
|
||||||
->default(false),
|
|
||||||
TextInput::make('position')
|
|
||||||
->label(__('admin.task_collections.fields.position'))
|
|
||||||
->numeric()
|
|
||||||
->default(0),
|
|
||||||
])
|
|
||||||
->columns(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->columns([
|
|
||||||
TextColumn::make('id')
|
|
||||||
->label('#')
|
|
||||||
->sortable(),
|
|
||||||
TextColumn::make('name')
|
|
||||||
->label(__('admin.task_collections.table.name'))
|
|
||||||
->getStateUsing(fn (TaskCollection $record) => static::formatTranslation($record->name_translations))
|
|
||||||
->searchable(['name_translations->de', 'name_translations->en'])
|
|
||||||
->limit(60),
|
|
||||||
TextColumn::make('eventType.name')
|
|
||||||
->label(__('admin.task_collections.table.event_type'))
|
|
||||||
->getStateUsing(function (TaskCollection $record) {
|
|
||||||
$value = optional($record->eventType)->name;
|
|
||||||
|
|
||||||
if (is_array($value)) {
|
|
||||||
$locale = app()->getLocale();
|
|
||||||
|
|
||||||
return $value[$locale] ?? ($value['de'] ?? ($value['en'] ?? ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (string) ($value ?? '');
|
|
||||||
})
|
|
||||||
->toggleable(),
|
|
||||||
TextColumn::make('slug')
|
|
||||||
->label(__('admin.task_collections.table.slug'))
|
|
||||||
->toggleable()
|
|
||||||
->searchable(),
|
|
||||||
IconColumn::make('is_default')
|
|
||||||
->label(__('admin.task_collections.table.is_default'))
|
|
||||||
->boolean(),
|
|
||||||
TextColumn::make('position')
|
|
||||||
->label(__('admin.task_collections.table.position'))
|
|
||||||
->sortable(),
|
|
||||||
TextColumn::make('tasks_count')
|
|
||||||
->label(__('admin.task_collections.table.tasks'))
|
|
||||||
->sortable(),
|
|
||||||
TextColumn::make('events_count')
|
|
||||||
->label(__('admin.task_collections.table.events'))
|
|
||||||
->sortable(),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
SelectFilter::make('event_type_id')
|
|
||||||
->label(__('admin.task_collections.table.event_type'))
|
|
||||||
->relationship(
|
|
||||||
'eventType',
|
|
||||||
'name',
|
|
||||||
fn (Builder $query): Builder => $query->orderBy('name->de')
|
|
||||||
)
|
|
||||||
->getOptionLabelFromRecordUsing(fn (EventType $record) => is_array($record->name) ? ($record->name['de'] ?? $record->name['en'] ?? __('admin.common.unnamed')) : $record->name),
|
|
||||||
SelectFilter::make('is_default')
|
|
||||||
->label(__('admin.task_collections.table.is_default'))
|
|
||||||
->options([
|
|
||||||
'1' => __('admin.common.yes'),
|
|
||||||
'0' => __('admin.common.no'),
|
|
||||||
]),
|
|
||||||
])
|
|
||||||
->recordActions([
|
|
||||||
Actions\EditAction::make()
|
|
||||||
->mutateDataUsing(fn (array $data, TaskCollection $record): array => static::normalizeData($data, $record))
|
|
||||||
->after(fn (array $data, TaskCollection $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
|
||||||
'updated',
|
|
||||||
$record,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata($data),
|
|
||||||
static::class
|
|
||||||
)),
|
|
||||||
Actions\DeleteAction::make()
|
|
||||||
->after(fn (TaskCollection $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
|
||||||
'deleted',
|
|
||||||
$record,
|
|
||||||
source: static::class
|
|
||||||
)),
|
|
||||||
])
|
|
||||||
->bulkActions([
|
|
||||||
Actions\DeleteBulkAction::make()
|
|
||||||
->after(function (Collection $records): void {
|
|
||||||
$logger = app(SuperAdminAuditLogger::class);
|
|
||||||
|
|
||||||
foreach ($records as $record) {
|
|
||||||
$logger->recordModelMutation(
|
|
||||||
'deleted',
|
|
||||||
$record,
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getNavigationLabel(): string
|
|
||||||
{
|
|
||||||
return __('admin.task_collections.menu');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getNavigationGroup(): UnitEnum|string|null
|
|
||||||
{
|
|
||||||
return __('admin.nav.curation');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
|
||||||
{
|
|
||||||
return parent::getEloquentQuery()
|
|
||||||
->whereNull('tenant_id')
|
|
||||||
->with('eventType')
|
|
||||||
->withCount(['tasks', 'events']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
*/
|
|
||||||
public static function normalizeData(array $data, ?TaskCollection $record = null): array
|
|
||||||
{
|
|
||||||
$data['tenant_id'] = null;
|
|
||||||
$data['slug'] = static::resolveSlug($data, $record);
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
*/
|
|
||||||
protected static function resolveSlug(array $data, ?TaskCollection $record = null): string
|
|
||||||
{
|
|
||||||
$rawSlug = trim((string) ($data['slug'] ?? ''));
|
|
||||||
$translations = Arr::wrap($data['name_translations'] ?? []);
|
|
||||||
$fallbackName = (string) ($translations['en'] ?? $translations['de'] ?? '');
|
|
||||||
|
|
||||||
$base = $rawSlug !== '' ? $rawSlug : $fallbackName;
|
|
||||||
$slugBase = Str::slug($base) ?: 'collection';
|
|
||||||
|
|
||||||
$query = TaskCollection::query()->where('slug', $slugBase);
|
|
||||||
|
|
||||||
if ($record) {
|
|
||||||
$query->whereKeyNot($record->getKey());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $query->exists()) {
|
|
||||||
return $slugBase;
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
$candidate = $slugBase.'-'.Str::random(4);
|
|
||||||
$candidateQuery = TaskCollection::query()->where('slug', $candidate);
|
|
||||||
|
|
||||||
if ($record) {
|
|
||||||
$candidateQuery->whereKeyNot($record->getKey());
|
|
||||||
}
|
|
||||||
} while ($candidateQuery->exists());
|
|
||||||
|
|
||||||
return $candidate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, string>|null $translations
|
|
||||||
*/
|
|
||||||
protected static function formatTranslation(?array $translations): string
|
|
||||||
{
|
|
||||||
if (! is_array($translations)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$locale = app()->getLocale();
|
|
||||||
|
|
||||||
return $translations[$locale]
|
|
||||||
?? ($translations['de'] ?? ($translations['en'] ?? Arr::first($translations) ?? ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'index' => ListTaskCollections::route('/'),
|
|
||||||
'create' => CreateTaskCollection::route('/create'),
|
|
||||||
'edit' => EditTaskCollection::route('/{record}/edit'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getRelations(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
TasksRelationManager::class,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,9 +13,7 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
class ImportEmotions extends Page
|
class ImportEmotions extends Page
|
||||||
{
|
{
|
||||||
protected static string $resource = EmotionResource::class;
|
protected static string $resource = EmotionResource::class;
|
||||||
|
|
||||||
protected string $view = 'filament.resources.emotion-resource.pages.import-emotions';
|
protected string $view = 'filament.resources.emotion-resource.pages.import-emotions';
|
||||||
|
|
||||||
protected ?string $heading = null;
|
protected ?string $heading = null;
|
||||||
|
|
||||||
public ?string $file = null;
|
public ?string $file = null;
|
||||||
@@ -38,7 +36,6 @@ class ImportEmotions extends Page
|
|||||||
$path = $this->form->getState()['file'] ?? null;
|
$path = $this->form->getState()['file'] ?? null;
|
||||||
if (! $path || ! Storage::disk('public')->exists($path)) {
|
if (! $path || ! Storage::disk('public')->exists($path)) {
|
||||||
Notification::make()->danger()->title(__('admin.notifications.file_not_found'))->send();
|
Notification::make()->danger()->title(__('admin.notifications.file_not_found'))->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,4 +16,4 @@ class ListEventPurchases extends ListRecords
|
|||||||
Actions\CreateAction::make(),
|
Actions\CreateAction::make(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,32 +60,19 @@ class EventResource extends Resource
|
|||||||
->required()
|
->required()
|
||||||
->unique(ignoreRecord: true)
|
->unique(ignoreRecord: true)
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
TextInput::make('join_link_display')
|
|
||||||
->label(__('admin.events.fields.join_link'))
|
|
||||||
->afterStateHydrated(function (TextInput $component, ?Event $record) {
|
|
||||||
if (! $record) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$token = $record->joinTokens()->latest()->first();
|
|
||||||
$component->state($token ? url('/e/'.$token->token) : '-');
|
|
||||||
})
|
|
||||||
->readOnly()
|
|
||||||
->dehydrated(false)
|
|
||||||
->visibleOn('edit'),
|
|
||||||
DatePicker::make('date')
|
DatePicker::make('date')
|
||||||
->label(__('admin.events.fields.date'))
|
->label(__('admin.events.fields.date'))
|
||||||
->required(),
|
->required(),
|
||||||
Select::make('event_type_id')
|
Select::make('event_type_id')
|
||||||
->label(__('admin.events.fields.type'))
|
->label(__('admin.events.fields.type'))
|
||||||
->options(fn () => EventType::all()->pluck('name.de', 'id'))
|
->options(EventType::query()->pluck('name', 'id'))
|
||||||
->searchable(),
|
->searchable(),
|
||||||
Select::make('package_id')
|
Select::make('package_id')
|
||||||
->label(__('admin.events.fields.package'))
|
->label(__('admin.events.fields.package'))
|
||||||
->options(\App\Models\Package::query()->where('type', 'endcustomer')->pluck('name', 'id'))
|
->options(\App\Models\Package::query()->where('type', 'endcustomer')->pluck('name', 'id'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->preload()
|
->preload()
|
||||||
->required()
|
->required(),
|
||||||
->visibleOn('create'),
|
|
||||||
TextInput::make('default_locale')
|
TextInput::make('default_locale')
|
||||||
->label(__('admin.events.fields.default_locale'))
|
->label(__('admin.events.fields.default_locale'))
|
||||||
->default('de')
|
->default('de')
|
||||||
@@ -109,13 +96,13 @@ class EventResource extends Resource
|
|||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('id')->sortable(),
|
Tables\Columns\TextColumn::make('id')->sortable(),
|
||||||
Tables\Columns\TextColumn::make('tenant.name')->label(__('admin.events.table.tenant'))->searchable(),
|
Tables\Columns\TextColumn::make('tenant.name')->label(__('admin.events.table.tenant'))->searchable(),
|
||||||
Tables\Columns\TextColumn::make('name')
|
Tables\Columns\TextColumn::make('name.de')
|
||||||
->label(__('admin.events.fields.name'))
|
->label(__('admin.events.fields.name'))
|
||||||
->formatStateUsing(fn (mixed $state): string => static::formatEventName($state))
|
|
||||||
->limit(30),
|
->limit(30),
|
||||||
Tables\Columns\TextColumn::make('slug')->searchable(),
|
Tables\Columns\TextColumn::make('slug')->searchable(),
|
||||||
Tables\Columns\TextColumn::make('date')->date(),
|
Tables\Columns\TextColumn::make('date')->date(),
|
||||||
Tables\Columns\IconColumn::make('is_active')->boolean(),
|
Tables\Columns\IconColumn::make('is_active')->boolean(),
|
||||||
|
Tables\Columns\TextColumn::make('default_locale'),
|
||||||
Tables\Columns\TextColumn::make('eventPackage.package.name')
|
Tables\Columns\TextColumn::make('eventPackage.package.name')
|
||||||
->label(__('admin.events.table.package'))
|
->label(__('admin.events.table.package'))
|
||||||
->badge()
|
->badge()
|
||||||
@@ -128,6 +115,22 @@ class EventResource extends Resource
|
|||||||
->badge()
|
->badge()
|
||||||
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
|
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
|
||||||
->getStateUsing(fn ($record) => $record->eventPackage?->remaining_photos ?? 0),
|
->getStateUsing(fn ($record) => $record->eventPackage?->remaining_photos ?? 0),
|
||||||
|
Tables\Columns\TextColumn::make('primary_join_token')
|
||||||
|
->label(__('admin.events.table.join'))
|
||||||
|
->getStateUsing(function ($record) {
|
||||||
|
$token = $record->joinTokens()->latest()->first();
|
||||||
|
|
||||||
|
return $token ? url('/e/'.$token->token) : __('admin.events.table.no_join_tokens');
|
||||||
|
})
|
||||||
|
->description(function ($record) {
|
||||||
|
$total = $record->joinTokens()->count();
|
||||||
|
|
||||||
|
return $total > 0
|
||||||
|
? __('admin.events.table.join_tokens_total', ['count' => $total])
|
||||||
|
: __('admin.events.table.join_tokens_missing');
|
||||||
|
})
|
||||||
|
->copyable()
|
||||||
|
->copyMessage(__('admin.events.messages.join_link_copied')),
|
||||||
Tables\Columns\TextColumn::make('created_at')->since(),
|
Tables\Columns\TextColumn::make('created_at')->since(),
|
||||||
])
|
])
|
||||||
->filters([])
|
->filters([])
|
||||||
@@ -279,30 +282,6 @@ class EventResource extends Resource
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed>|string|null $name
|
|
||||||
*/
|
|
||||||
private static function formatEventName(mixed $name): string
|
|
||||||
{
|
|
||||||
if (is_array($name)) {
|
|
||||||
$candidates = [
|
|
||||||
$name['de'] ?? null,
|
|
||||||
$name['en'] ?? null,
|
|
||||||
reset($name) ?: null,
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($candidates as $candidate) {
|
|
||||||
if (is_string($candidate) && $candidate !== '') {
|
|
||||||
return $candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return is_string($name) ? $name : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -8,25 +8,4 @@ use App\Filament\Resources\Pages\AuditedCreateRecord;
|
|||||||
class CreateEvent extends AuditedCreateRecord
|
class CreateEvent extends AuditedCreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = EventResource::class;
|
protected static string $resource = EventResource::class;
|
||||||
|
|
||||||
public ?int $packageId = null;
|
|
||||||
|
|
||||||
protected function mutateFormDataBeforeCreate(array $data): array
|
|
||||||
{
|
|
||||||
$this->packageId = $data['package_id'] ?? null;
|
|
||||||
unset($data['package_id']);
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function afterCreate(): void
|
|
||||||
{
|
|
||||||
if ($this->packageId) {
|
|
||||||
$this->record->eventPackages()->create([
|
|
||||||
'package_id' => $this->packageId,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
parent::afterCreate();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use Filament\Tables\Table;
|
|||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
|
|
||||||
class EventPackagesRelationManager extends RelationManager
|
class EventPackagesRelationManager extends RelationManager
|
||||||
{
|
{
|
||||||
@@ -58,7 +59,6 @@ class EventPackagesRelationManager extends RelationManager
|
|||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->modifyQueryUsing(fn (Builder $query) => $query->with('package'))
|
|
||||||
->recordTitleAttribute('package.name')
|
->recordTitleAttribute('package.name')
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('package.name')
|
TextColumn::make('package.name')
|
||||||
@@ -147,4 +147,9 @@ class EventPackagesRelationManager extends RelationManager
|
|||||||
{
|
{
|
||||||
return __('admin.events.relation_managers.event_packages.title');
|
return __('admin.events.relation_managers.event_packages.title');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getTableQuery(): Builder|Relation
|
||||||
|
{
|
||||||
|
return parent::getTableQuery()->with('package');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,64 +113,18 @@ class EventTypeResource extends Resource
|
|||||||
SuperAdminAuditLogger::fieldsMetadata($data),
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
static::class
|
static::class
|
||||||
)),
|
)),
|
||||||
Actions\DeleteAction::make()
|
|
||||||
->action(function (EventType $record, Actions\DeleteAction $action) {
|
|
||||||
try {
|
|
||||||
$record->delete();
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$isConstraint = ($e instanceof \Illuminate\Database\QueryException && ($e->getCode() == 23000 || ($e->errorInfo[0] ?? '') == 23000));
|
|
||||||
|
|
||||||
if ($isConstraint) {
|
|
||||||
\Filament\Notifications\Notification::make()
|
|
||||||
->title(__('admin.common.error'))
|
|
||||||
->body(__('admin.event_types.messages.delete_constraint_error'))
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
$action->halt();
|
|
||||||
}
|
|
||||||
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
->after(fn (EventType $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
|
||||||
'deleted',
|
|
||||||
$record,
|
|
||||||
source: static::class
|
|
||||||
)),
|
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
Actions\DeleteBulkAction::make()
|
Actions\DeleteBulkAction::make()
|
||||||
->action(function (Collection $records, Actions\DeleteBulkAction $action) {
|
->after(function (Collection $records): void {
|
||||||
$logger = app(SuperAdminAuditLogger::class);
|
$logger = app(SuperAdminAuditLogger::class);
|
||||||
$deletedCount = 0;
|
|
||||||
$failedCount = 0;
|
|
||||||
|
|
||||||
foreach ($records as $record) {
|
foreach ($records as $record) {
|
||||||
try {
|
$logger->recordModelMutation(
|
||||||
$record->delete();
|
'deleted',
|
||||||
$logger->recordModelMutation('deleted', $record, source: static::class);
|
$record,
|
||||||
$deletedCount++;
|
source: static::class
|
||||||
} catch (\Exception $e) {
|
);
|
||||||
$isConstraint = ($e instanceof \Illuminate\Database\QueryException && ($e->getCode() == 23000 || ($e->errorInfo[0] ?? '') == 23000));
|
|
||||||
if ($isConstraint) {
|
|
||||||
$failedCount++;
|
|
||||||
} else {
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($failedCount > 0) {
|
|
||||||
\Filament\Notifications\Notification::make()
|
|
||||||
->title(__('admin.common.error'))
|
|
||||||
->body(__('admin.event_types.messages.delete_constraint_error')." ($failedCount failed, $deletedCount deleted)")
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
if ($deletedCount === 0) {
|
|
||||||
$action->halt();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ class ListMediaStorageTargets extends ListRecords
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ class PackageResource extends Resource
|
|||||||
->nullable()
|
->nullable()
|
||||||
->visible(fn ($get) => $get('type') === 'reseller'),
|
->visible(fn ($get) => $get('type') === 'reseller'),
|
||||||
Toggle::make('watermark_allowed')
|
Toggle::make('watermark_allowed')
|
||||||
->label('Eigenes Wasserzeichen erlaubt')
|
->label('Wasserzeichen erlaubt')
|
||||||
->default(true),
|
->default(true),
|
||||||
Toggle::make('branding_allowed')
|
Toggle::make('branding_allowed')
|
||||||
->label('Eigenes Branding erlaubt')
|
->label('Eigenes Branding erlaubt')
|
||||||
|
|||||||
@@ -16,4 +16,4 @@ class ListPackages extends ListRecords
|
|||||||
Actions\CreateAction::make(),
|
Actions\CreateAction::make(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,3 +14,4 @@ class ListPurchaseHistories extends ListRecords
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ class ViewPurchaseHistory extends ViewRecord
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,4 +16,4 @@ class ListPurchases extends ListRecords
|
|||||||
Actions\CreateAction::make(),
|
Actions\CreateAction::make(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,6 @@ use App\Filament\Resources\TenantResource\RelationManagers\PackagePurchasesRelat
|
|||||||
use App\Filament\Resources\TenantResource\RelationManagers\TenantPackagesRelationManager;
|
use App\Filament\Resources\TenantResource\RelationManagers\TenantPackagesRelationManager;
|
||||||
use App\Filament\Resources\TenantResource\Schemas\TenantInfolist;
|
use App\Filament\Resources\TenantResource\Schemas\TenantInfolist;
|
||||||
use App\Jobs\AnonymizeAccount;
|
use App\Jobs\AnonymizeAccount;
|
||||||
use App\Models\Package;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Notifications\InactiveTenantDeletionWarning;
|
use App\Notifications\InactiveTenantDeletionWarning;
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
@@ -206,13 +205,11 @@ class TenantResource extends Resource
|
|||||||
Forms\Components\Textarea::make('reason')->label('Grund')->rows(3),
|
Forms\Components\Textarea::make('reason')->label('Grund')->rows(3),
|
||||||
])
|
])
|
||||||
->action(function (Tenant $record, array $data) {
|
->action(function (Tenant $record, array $data) {
|
||||||
$package = Package::query()->find($data['package_id']);
|
|
||||||
\App\Models\TenantPackage::create([
|
\App\Models\TenantPackage::create([
|
||||||
'tenant_id' => $record->id,
|
'tenant_id' => $record->id,
|
||||||
'package_id' => $data['package_id'],
|
'package_id' => $data['package_id'],
|
||||||
'expires_at' => $data['expires_at'],
|
'expires_at' => $data['expires_at'],
|
||||||
'active' => true,
|
'active' => true,
|
||||||
'price' => $package?->price ?? 0,
|
|
||||||
'reason' => $data['reason'] ?? null,
|
'reason' => $data['reason'] ?? null,
|
||||||
]);
|
]);
|
||||||
\App\Models\PackagePurchase::create([
|
\App\Models\PackagePurchase::create([
|
||||||
|
|||||||
@@ -3,78 +3,39 @@
|
|||||||
namespace App\Filament\SuperAdmin\Pages\Auth;
|
namespace App\Filament\SuperAdmin\Pages\Auth;
|
||||||
|
|
||||||
use Filament\Auth\Pages\EditProfile as BaseEditProfile;
|
use Filament\Auth\Pages\EditProfile as BaseEditProfile;
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Forms\Components\Select;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Schemas\Components\Component;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Schemas\Components\Livewire;
|
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Schemas\Components\Utilities\Get;
|
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class EditProfile extends BaseEditProfile
|
class EditProfile extends BaseEditProfile
|
||||||
{
|
{
|
||||||
protected function getPasswordConfirmationFormComponent(): Component
|
public function mount(): void
|
||||||
{
|
{
|
||||||
return TextInput::make('passwordConfirmation')
|
Log::info('EditProfile class loaded for superadmin');
|
||||||
->label(__('filament-panels::auth/pages/edit-profile.form.password_confirmation.label'))
|
parent::mount();
|
||||||
->validationAttribute(__('filament-panels::auth/pages/edit-profile.form.password_confirmation.validation_attribute'))
|
|
||||||
->password()
|
|
||||||
->autocomplete('new-password')
|
|
||||||
->revealable(filament()->arePasswordsRevealable())
|
|
||||||
->required()
|
|
||||||
->visible(fn (Get $get): bool => filled($get('password')))
|
|
||||||
->dehydrated(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getCurrentPasswordFormComponent(): Component
|
|
||||||
{
|
|
||||||
return TextInput::make('currentPassword')
|
|
||||||
->label(__('filament-panels::auth/pages/edit-profile.form.current_password.label'))
|
|
||||||
->validationAttribute(__('filament-panels::auth/pages/edit-profile.form.current_password.validation_attribute'))
|
|
||||||
->belowContent(__('filament-panels::auth/pages/edit-profile.form.current_password.below_content'))
|
|
||||||
->password()
|
|
||||||
->autocomplete('current-password')
|
|
||||||
->currentPassword(guard: Filament::getAuthGuard())
|
|
||||||
->revealable(filament()->arePasswordsRevealable())
|
|
||||||
->required()
|
|
||||||
->visible(fn (Get $get): bool => filled($get('password')))
|
|
||||||
->dehydrated(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function form(Schema $schema): Schema
|
public function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
->schema([
|
->schema([
|
||||||
Section::make('Profile')
|
$this->getNameFormComponent(),
|
||||||
->schema([
|
$this->getEmailFormComponent(),
|
||||||
$this->getNameFormComponent(),
|
TextInput::make('username')
|
||||||
$this->getEmailFormComponent(),
|
->required()
|
||||||
TextInput::make('username')
|
->unique(ignoreRecord: true)
|
||||||
->required()
|
->maxLength(255),
|
||||||
->unique(ignoreRecord: true)
|
Select::make('preferred_locale')
|
||||||
->maxLength(255),
|
->options([
|
||||||
Select::make('preferred_locale')
|
'de' => 'Deutsch',
|
||||||
->options([
|
'en' => 'English',
|
||||||
'de' => 'Deutsch',
|
|
||||||
'en' => 'English',
|
|
||||||
])
|
|
||||||
->default('de')
|
|
||||||
->required(),
|
|
||||||
])
|
])
|
||||||
->columns(2),
|
->default('de')
|
||||||
Section::make('Security')
|
->required(),
|
||||||
->schema([
|
$this->getPasswordFormComponent(),
|
||||||
$this->getPasswordFormComponent(),
|
$this->getPasswordConfirmationFormComponent(),
|
||||||
$this->getPasswordConfirmationFormComponent(),
|
$this->getCurrentPasswordFormComponent(),
|
||||||
$this->getCurrentPasswordFormComponent(),
|
|
||||||
])
|
|
||||||
->columns(1),
|
|
||||||
Section::make('Support API Tokens')
|
|
||||||
->description('Manage bearer tokens for external support tooling.')
|
|
||||||
->schema([
|
|
||||||
Livewire::make('support-api-token-manager'),
|
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,7 @@ class WatermarkSettingsPage extends Page
|
|||||||
return __('admin.nav.branding');
|
return __('admin.nav.branding');
|
||||||
}
|
}
|
||||||
|
|
||||||
public $asset = [];
|
public ?string $asset = null;
|
||||||
|
|
||||||
public string $position = 'bottom-right';
|
public string $position = 'bottom-right';
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ class WatermarkSettingsPage extends Page
|
|||||||
$settings = WatermarkSetting::query()->first();
|
$settings = WatermarkSetting::query()->first();
|
||||||
|
|
||||||
if ($settings) {
|
if ($settings) {
|
||||||
$this->asset = $settings->asset ? [$settings->asset] : [];
|
$this->asset = $settings->asset;
|
||||||
$this->position = $settings->position;
|
$this->position = $settings->position;
|
||||||
$this->opacity = (float) $settings->opacity;
|
$this->opacity = (float) $settings->opacity;
|
||||||
$this->scale = (float) $settings->scale;
|
$this->scale = (float) $settings->scale;
|
||||||
@@ -119,14 +119,8 @@ class WatermarkSettingsPage extends Page
|
|||||||
{
|
{
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
|
||||||
$state = $this->form->getState();
|
|
||||||
$asset = $state['asset'] ?? $this->asset;
|
|
||||||
if (is_array($asset)) {
|
|
||||||
$asset = $asset[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$settings = WatermarkSetting::query()->firstOrNew([]);
|
$settings = WatermarkSetting::query()->firstOrNew([]);
|
||||||
$settings->asset = $asset;
|
$settings->asset = $this->asset;
|
||||||
$settings->position = $this->position;
|
$settings->position = $this->position;
|
||||||
$settings->opacity = $this->opacity;
|
$settings->opacity = $this->opacity;
|
||||||
$settings->scale = $this->scale;
|
$settings->scale = $this->scale;
|
||||||
|
|||||||
@@ -1,280 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\SuperAdmin\Widgets;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Forms\Components\CheckboxList;
|
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Filament\Widgets\TableWidget;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Laravel\Sanctum\NewAccessToken;
|
|
||||||
use Laravel\Sanctum\PersonalAccessToken;
|
|
||||||
|
|
||||||
class SupportApiTokenManager extends TableWidget
|
|
||||||
{
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
|
||||||
|
|
||||||
public function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->heading('Support API Tokens')
|
|
||||||
->query(fn (): Builder => $this->getTokenQuery())
|
|
||||||
->defaultSort('created_at', 'desc')
|
|
||||||
->columns([
|
|
||||||
Tables\Columns\TextColumn::make('name')
|
|
||||||
->label('Name')
|
|
||||||
->sortable()
|
|
||||||
->searchable(),
|
|
||||||
Tables\Columns\TextColumn::make('abilities')
|
|
||||||
->label('Abilities')
|
|
||||||
->formatStateUsing(fn ($state): string => $this->formatAbilities($state))
|
|
||||||
->wrap(),
|
|
||||||
Tables\Columns\TextColumn::make('last_used_at')
|
|
||||||
->label('Last used')
|
|
||||||
->since()
|
|
||||||
->placeholder('—'),
|
|
||||||
Tables\Columns\TextColumn::make('expires_at')
|
|
||||||
->label('Expires')
|
|
||||||
->dateTime('Y-m-d H:i')
|
|
||||||
->placeholder('—'),
|
|
||||||
Tables\Columns\TextColumn::make('created_at')
|
|
||||||
->label('Created')
|
|
||||||
->since(),
|
|
||||||
])
|
|
||||||
->headerActions([
|
|
||||||
Action::make('create_support_token')
|
|
||||||
->label('Create token')
|
|
||||||
->icon('heroicon-o-key')
|
|
||||||
->form([
|
|
||||||
TextInput::make('name')
|
|
||||||
->label('Token name')
|
|
||||||
->default($this->defaultTokenName())
|
|
||||||
->required()
|
|
||||||
->maxLength(255)
|
|
||||||
->helperText('Existing tokens with the same name will be revoked.'),
|
|
||||||
CheckboxList::make('abilities')
|
|
||||||
->label('Abilities')
|
|
||||||
->options($this->abilityOptions())
|
|
||||||
->columns(2)
|
|
||||||
->required()
|
|
||||||
->default($this->defaultAbilities()),
|
|
||||||
DateTimePicker::make('expires_at')
|
|
||||||
->label('Expires at')
|
|
||||||
->displayFormat('Y-m-d H:i')
|
|
||||||
->seconds(false),
|
|
||||||
])
|
|
||||||
->action(function (array $data): void {
|
|
||||||
$user = $this->getUser();
|
|
||||||
|
|
||||||
if (! $user) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$name = $this->normalizeTokenName($data['name'] ?? null);
|
|
||||||
$abilities = $this->normalizeAbilities($data['abilities'] ?? []);
|
|
||||||
$expiresAt = $this->normalizeExpiresAt($data['expires_at'] ?? null);
|
|
||||||
|
|
||||||
$user->tokens()->where('name', $name)->delete();
|
|
||||||
|
|
||||||
$token = $user->createToken($name, $abilities, $expiresAt);
|
|
||||||
|
|
||||||
$this->recordTokenCreated($token, $abilities, $user);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->success()
|
|
||||||
->title('Token created')
|
|
||||||
->body('Copy this token now. It will not be shown again: '.$token->plainTextToken)
|
|
||||||
->persistent()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
Action::make('revoke')
|
|
||||||
->label('Revoke')
|
|
||||||
->icon('heroicon-o-trash')
|
|
||||||
->color('danger')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (PersonalAccessToken $record): bool => $this->ownsToken($record))
|
|
||||||
->action(function (PersonalAccessToken $record): void {
|
|
||||||
if (! $this->ownsToken($record)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'support-api-token.revoked',
|
|
||||||
$record,
|
|
||||||
['fields' => ['name', 'abilities', 'expires_at']],
|
|
||||||
actor: $this->getUser(),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
$record->delete();
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->success()
|
|
||||||
->title('Token revoked')
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->emptyStateHeading('No support API tokens')
|
|
||||||
->emptyStateDescription('Create a token for external support tooling.');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getTokenQuery(): Builder
|
|
||||||
{
|
|
||||||
$user = $this->getUser();
|
|
||||||
|
|
||||||
if (! $user) {
|
|
||||||
return PersonalAccessToken::query()->whereRaw('1 = 0');
|
|
||||||
}
|
|
||||||
|
|
||||||
return PersonalAccessToken::query()
|
|
||||||
->where('tokenable_id', $user->getKey())
|
|
||||||
->where('tokenable_type', $user->getMorphClass());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getUser(): ?User
|
|
||||||
{
|
|
||||||
$user = Filament::auth()->user();
|
|
||||||
|
|
||||||
return $user instanceof User ? $user : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function formatAbilities(mixed $state): string
|
|
||||||
{
|
|
||||||
if (is_array($state)) {
|
|
||||||
return implode(', ', $state);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($state)) {
|
|
||||||
return $state;
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private function defaultAbilities(): array
|
|
||||||
{
|
|
||||||
$abilities = config('support-api.token.default_abilities', []);
|
|
||||||
|
|
||||||
if (! is_array($abilities)) {
|
|
||||||
return ['support-admin'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$abilities = array_values(array_filter($abilities, fn ($ability) => is_string($ability) && $ability !== ''));
|
|
||||||
|
|
||||||
if (! in_array('support-admin', $abilities, true)) {
|
|
||||||
$abilities[] = 'support-admin';
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_unique($abilities));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private function abilityOptions(): array
|
|
||||||
{
|
|
||||||
$options = [];
|
|
||||||
|
|
||||||
foreach ($this->defaultAbilities() as $ability) {
|
|
||||||
$options[$ability] = $ability;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $options;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, string> $abilities
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private function normalizeAbilities(array $abilities): array
|
|
||||||
{
|
|
||||||
$allowed = $this->defaultAbilities();
|
|
||||||
$filtered = array_values(array_intersect($abilities, $allowed));
|
|
||||||
|
|
||||||
if (! in_array('support-admin', $filtered, true)) {
|
|
||||||
$filtered[] = 'support-admin';
|
|
||||||
}
|
|
||||||
|
|
||||||
sort($filtered);
|
|
||||||
|
|
||||||
return $filtered;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function defaultTokenName(): string
|
|
||||||
{
|
|
||||||
$name = config('support-api.token.name');
|
|
||||||
|
|
||||||
if (is_string($name) && $name !== '') {
|
|
||||||
return $name;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'support-api';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeTokenName(?string $name): string
|
|
||||||
{
|
|
||||||
$name = $name ? trim($name) : '';
|
|
||||||
|
|
||||||
return $name !== '' ? $name : $this->defaultTokenName();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeExpiresAt(mixed $expiresAt): ?Carbon
|
|
||||||
{
|
|
||||||
if ($expiresAt instanceof Carbon) {
|
|
||||||
return $expiresAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($expiresAt instanceof \DateTimeInterface) {
|
|
||||||
return Carbon::instance($expiresAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($expiresAt) && $expiresAt !== '') {
|
|
||||||
return Carbon::parse($expiresAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function recordTokenCreated(NewAccessToken $token, array $abilities, User $user): void
|
|
||||||
{
|
|
||||||
$actionLog = app(SuperAdminAuditLogger::class);
|
|
||||||
|
|
||||||
$actionLog->record(
|
|
||||||
'support-api-token.created',
|
|
||||||
$token->accessToken,
|
|
||||||
[
|
|
||||||
'fields' => ['name', 'abilities', 'expires_at'],
|
|
||||||
'abilities' => $abilities,
|
|
||||||
],
|
|
||||||
actor: $user,
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function ownsToken(PersonalAccessToken $token): bool
|
|
||||||
{
|
|
||||||
$user = $this->getUser();
|
|
||||||
|
|
||||||
if (! $user) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) $token->tokenable_id === (int) $user->getKey()
|
|
||||||
&& $token->tokenable_type === $user->getMorphClass();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,88 +14,11 @@ class DokployPlatformHealth extends Widget
|
|||||||
|
|
||||||
protected function getViewData(): array
|
protected function getViewData(): array
|
||||||
{
|
{
|
||||||
$projects = $this->loadProjects();
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'projects' => $projects,
|
'composes' => $this->loadComposes(),
|
||||||
'composes' => empty($projects) ? $this->loadComposes() : [],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function loadProjects(): array
|
|
||||||
{
|
|
||||||
$client = app(DokployClient::class);
|
|
||||||
$projectMap = config('dokploy.projects', []);
|
|
||||||
$results = [];
|
|
||||||
|
|
||||||
if (empty($projectMap)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($projectMap as $label => $projectId) {
|
|
||||||
$project = [];
|
|
||||||
$projectIdString = (string) $projectId;
|
|
||||||
|
|
||||||
try {
|
|
||||||
$project = $client->project($projectIdString);
|
|
||||||
} catch (\Throwable $exception) {
|
|
||||||
$project = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($project)) {
|
|
||||||
$project = $client->findProject($projectIdString) ?? [];
|
|
||||||
|
|
||||||
$resolvedProjectId = Arr::get($project, 'projectId');
|
|
||||||
|
|
||||||
if ($resolvedProjectId) {
|
|
||||||
try {
|
|
||||||
$project = $client->project((string) $resolvedProjectId);
|
|
||||||
} catch (\Throwable $exception) {
|
|
||||||
$project = $project;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $project) {
|
|
||||||
$results[] = [
|
|
||||||
'label' => ucfirst((string) $label),
|
|
||||||
'project_id' => $projectIdString,
|
|
||||||
'name' => $projectIdString,
|
|
||||||
'status' => 'unreachable',
|
|
||||||
'error' => "Project {$projectIdString} not found.",
|
|
||||||
'applications' => [],
|
|
||||||
'services' => [],
|
|
||||||
'composes' => [],
|
|
||||||
'updated_at' => null,
|
|
||||||
];
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$environments = $this->extractEnvironments($project);
|
|
||||||
$applications = $this->formatEnvironmentApplications($environments, $client);
|
|
||||||
$composes = $this->formatEnvironmentComposes($environments, $client);
|
|
||||||
$services = $this->formatEnvironmentServices($environments);
|
|
||||||
|
|
||||||
$results[] = [
|
|
||||||
'label' => ucfirst((string) $label),
|
|
||||||
'project_id' => Arr::get($project, 'projectId', $projectIdString),
|
|
||||||
'name' => Arr::get($project, 'name') ?? Arr::get($project, 'projectName') ?? $projectIdString,
|
|
||||||
'description' => Arr::get($project, 'description'),
|
|
||||||
'status' => $this->deriveProjectStatus($applications, $services, $composes),
|
|
||||||
'applications' => $applications,
|
|
||||||
'composes' => $composes,
|
|
||||||
'services' => $services,
|
|
||||||
'updated_at' => Arr::get($project, 'updatedAt') ?? Arr::get($project, 'createdAt'),
|
|
||||||
'applications_count' => count($applications),
|
|
||||||
'composes_count' => count($composes),
|
|
||||||
'services_count' => count($services),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $results;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function loadComposes(): array
|
protected function loadComposes(): array
|
||||||
{
|
{
|
||||||
$client = app(DokployClient::class);
|
$client = app(DokployClient::class);
|
||||||
@@ -139,7 +62,7 @@ class DokployPlatformHealth extends Widget
|
|||||||
'label' => 'Dokploy',
|
'label' => 'Dokploy',
|
||||||
'compose_id' => '-',
|
'compose_id' => '-',
|
||||||
'status' => 'unconfigured',
|
'status' => 'unconfigured',
|
||||||
'error' => 'Set DOKPLOY_PROJECT_IDS or DOKPLOY_COMPOSE_IDS in .env to enable monitoring.',
|
'error' => 'Set DOKPLOY_COMPOSE_IDS in .env to enable monitoring.',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -147,252 +70,6 @@ class DokployPlatformHealth extends Widget
|
|||||||
return $results;
|
return $results;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function extractEnvironments(array $project): array
|
|
||||||
{
|
|
||||||
$environments = Arr::get($project, 'environments', []);
|
|
||||||
|
|
||||||
if (is_array($environments) && ! empty($environments)) {
|
|
||||||
return $environments;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [[
|
|
||||||
'name' => Arr::get($project, 'name'),
|
|
||||||
'applications' => Arr::get($project, 'applications', []),
|
|
||||||
'compose' => Arr::get($project, 'compose', []),
|
|
||||||
'mysql' => Arr::get($project, 'mysql', []),
|
|
||||||
'postgres' => Arr::get($project, 'postgres', []),
|
|
||||||
'mariadb' => Arr::get($project, 'mariadb', []),
|
|
||||||
'mongo' => Arr::get($project, 'mongo', []),
|
|
||||||
'redis' => Arr::get($project, 'redis', []),
|
|
||||||
]];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function formatEnvironmentApplications(array $environments, DokployClient $client): array
|
|
||||||
{
|
|
||||||
return collect($environments)
|
|
||||||
->flatMap(function (array $environment) use ($client) {
|
|
||||||
$applications = Arr::get($environment, 'applications', []);
|
|
||||||
$environmentName = Arr::get($environment, 'name');
|
|
||||||
|
|
||||||
return $this->formatApplications(is_array($applications) ? $applications : [], $client, $environmentName);
|
|
||||||
})
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function formatEnvironmentComposes(array $environments, DokployClient $client): array
|
|
||||||
{
|
|
||||||
return collect($environments)
|
|
||||||
->flatMap(function (array $environment) use ($client) {
|
|
||||||
$composes = Arr::get($environment, 'compose', []);
|
|
||||||
$environmentName = Arr::get($environment, 'name');
|
|
||||||
|
|
||||||
return collect(is_array($composes) ? $composes : [])
|
|
||||||
->map(function (array $compose) use ($client, $environmentName) {
|
|
||||||
$composeId = Arr::get($compose, 'composeId') ?? Arr::get($compose, 'id');
|
|
||||||
$statusPayload = [];
|
|
||||||
$deployments = [];
|
|
||||||
|
|
||||||
if ($composeId) {
|
|
||||||
try {
|
|
||||||
$statusPayload = $client->composeStatus($composeId);
|
|
||||||
$deployments = $client->composeDeployments($composeId, 1);
|
|
||||||
} catch (\Throwable $exception) {
|
|
||||||
$statusPayload = [];
|
|
||||||
$deployments = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$composeDetails = Arr::get($statusPayload, 'compose', []);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'id' => $composeId,
|
|
||||||
'name' => Arr::get($compose, 'name')
|
|
||||||
?? Arr::get($compose, 'appName')
|
|
||||||
?? Arr::get($composeDetails, 'name')
|
|
||||||
?? Arr::get($composeDetails, 'appName')
|
|
||||||
?? $composeId,
|
|
||||||
'status' => Arr::get($compose, 'composeStatus')
|
|
||||||
?? Arr::get($compose, 'status')
|
|
||||||
?? Arr::get($composeDetails, 'composeStatus')
|
|
||||||
?? Arr::get($composeDetails, 'status')
|
|
||||||
?? 'unknown',
|
|
||||||
'environment' => $environmentName,
|
|
||||||
'last_deploy' => Arr::get($deployments, '0.createdAt')
|
|
||||||
?? Arr::get($deployments, '0.created_at')
|
|
||||||
?? Arr::get($compose, 'updatedAt')
|
|
||||||
?? Arr::get($composeDetails, 'updatedAt'),
|
|
||||||
'services' => $this->formatServices(Arr::get($statusPayload, 'services', [])),
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->filter(fn (array $compose) => filled($compose['name']))
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
})
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function formatEnvironmentServices(array $environments): array
|
|
||||||
{
|
|
||||||
return collect($environments)
|
|
||||||
->flatMap(function (array $environment) {
|
|
||||||
$environmentName = Arr::get($environment, 'name');
|
|
||||||
|
|
||||||
return collect([
|
|
||||||
...$this->normalizeServiceList((array) Arr::get($environment, 'compose', []), 'compose', 'composeId', 'composeStatus', $environmentName),
|
|
||||||
...$this->normalizeServiceList((array) Arr::get($environment, 'mysql', []), 'mysql', 'mysqlId', 'applicationStatus', $environmentName),
|
|
||||||
...$this->normalizeServiceList((array) Arr::get($environment, 'postgres', []), 'postgres', 'postgresId', 'applicationStatus', $environmentName),
|
|
||||||
...$this->normalizeServiceList((array) Arr::get($environment, 'mariadb', []), 'mariadb', 'mariadbId', 'applicationStatus', $environmentName),
|
|
||||||
...$this->normalizeServiceList((array) Arr::get($environment, 'mongo', []), 'mongo', 'mongoId', 'applicationStatus', $environmentName),
|
|
||||||
...$this->normalizeServiceList((array) Arr::get($environment, 'redis', []), 'redis', 'redisId', 'applicationStatus', $environmentName),
|
|
||||||
]);
|
|
||||||
})
|
|
||||||
->filter(fn (array $service) => filled($service['name']))
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function formatApplications(array $applications, DokployClient $client, ?string $environment = null): array
|
|
||||||
{
|
|
||||||
return collect($applications)
|
|
||||||
->map(function (array $application) use ($client, $environment) {
|
|
||||||
$applicationId = $this->extractApplicationId($application);
|
|
||||||
$statusPayload = [];
|
|
||||||
|
|
||||||
if ($applicationId) {
|
|
||||||
try {
|
|
||||||
$statusPayload = $client->applicationStatus($applicationId);
|
|
||||||
} catch (\Throwable $exception) {
|
|
||||||
$statusPayload = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$applicationDetails = Arr::get($statusPayload, 'application', []);
|
|
||||||
$monitoring = Arr::get($statusPayload, 'monitoring', []);
|
|
||||||
|
|
||||||
$status = Arr::get($application, 'applicationStatus')
|
|
||||||
?? Arr::get($application, 'status')
|
|
||||||
?? Arr::get($applicationDetails, 'applicationStatus')
|
|
||||||
?? Arr::get($applicationDetails, 'status')
|
|
||||||
?? 'unknown';
|
|
||||||
|
|
||||||
return [
|
|
||||||
'id' => $applicationId ?? Arr::get($application, 'id'),
|
|
||||||
'name' => Arr::get($application, 'name')
|
|
||||||
?? Arr::get($application, 'appName')
|
|
||||||
?? Arr::get($applicationDetails, 'name')
|
|
||||||
?? Arr::get($applicationDetails, 'appName')
|
|
||||||
?? $applicationId,
|
|
||||||
'status' => $status,
|
|
||||||
'repository' => Arr::get($application, 'repository')
|
|
||||||
?? Arr::get($applicationDetails, 'repository')
|
|
||||||
?? Arr::get($application, 'repo')
|
|
||||||
?? Arr::get($applicationDetails, 'repo'),
|
|
||||||
'branch' => Arr::get($application, 'branch')
|
|
||||||
?? Arr::get($applicationDetails, 'branch')
|
|
||||||
?? Arr::get($application, 'gitBranch')
|
|
||||||
?? Arr::get($applicationDetails, 'gitBranch'),
|
|
||||||
'url' => Arr::get($application, 'url')
|
|
||||||
?? Arr::get($applicationDetails, 'url')
|
|
||||||
?? Arr::get($application, 'domain')
|
|
||||||
?? Arr::get($applicationDetails, 'domain'),
|
|
||||||
'server' => Arr::get($application, 'serverName')
|
|
||||||
?? Arr::get($applicationDetails, 'serverName')
|
|
||||||
?? Arr::get($application, 'server'),
|
|
||||||
'environment' => $environment,
|
|
||||||
'last_deploy' => Arr::get($application, 'lastDeploymentAt')
|
|
||||||
?? Arr::get($applicationDetails, 'lastDeploymentAt')
|
|
||||||
?? Arr::get($application, 'updatedAt')
|
|
||||||
?? Arr::get($applicationDetails, 'updatedAt')
|
|
||||||
?? Arr::get($application, 'createdAt'),
|
|
||||||
'monitoring' => $this->formatMonitoring($monitoring),
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->filter(fn (array $application) => filled($application['name']))
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function extractApplicationId(array $application): ?string
|
|
||||||
{
|
|
||||||
return Arr::get($application, 'applicationId')
|
|
||||||
?? Arr::get($application, 'appId')
|
|
||||||
?? Arr::get($application, 'id');
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function normalizeServiceList(array $services, string $type, string $idKey, string $statusKey, ?string $environment = null): array
|
|
||||||
{
|
|
||||||
return collect($services)
|
|
||||||
->map(function (array $service) use ($type, $idKey, $statusKey, $environment) {
|
|
||||||
return [
|
|
||||||
'type' => $type,
|
|
||||||
'id' => Arr::get($service, $idKey) ?? Arr::get($service, 'id'),
|
|
||||||
'name' => Arr::get($service, 'name') ?? Arr::get($service, 'appName') ?? Arr::get($service, 'serviceName'),
|
|
||||||
'status' => Arr::get($service, $statusKey) ?? Arr::get($service, 'status') ?? Arr::get($service, 'composeStatus', 'unknown'),
|
|
||||||
'version' => Arr::get($service, 'dockerImage') ?? Arr::get($service, 'image'),
|
|
||||||
'external_port' => Arr::get($service, 'externalPort'),
|
|
||||||
'environment' => $environment,
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function formatMonitoring(array $monitoring): array
|
|
||||||
{
|
|
||||||
$metrics = [];
|
|
||||||
$allowed = [
|
|
||||||
'cpuPercent' => 'CPU',
|
|
||||||
'cpu' => 'CPU',
|
|
||||||
'memoryPercent' => 'Memory',
|
|
||||||
'memory' => 'Memory',
|
|
||||||
'uptime' => 'Uptime',
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($allowed as $key => $label) {
|
|
||||||
$value = Arr::get($monitoring, $key);
|
|
||||||
|
|
||||||
if (filled($value) && ! is_array($value)) {
|
|
||||||
$metrics[] = [
|
|
||||||
'label' => $label,
|
|
||||||
'value' => $value,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $metrics;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function deriveProjectStatus(array $applications, array $services, array $composes): string
|
|
||||||
{
|
|
||||||
$statuses = collect($applications)
|
|
||||||
->pluck('status')
|
|
||||||
->merge(collect($services)->pluck('status'))
|
|
||||||
->merge(collect($composes)->pluck('status'))
|
|
||||||
->filter()
|
|
||||||
->map(fn ($status) => strtolower((string) $status))
|
|
||||||
->values();
|
|
||||||
|
|
||||||
if ($statuses->contains(fn ($status) => in_array($status, ['error', 'failed', 'unreachable', 'unhealthy'], true))) {
|
|
||||||
return 'error';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($statuses->contains(fn ($status) => in_array($status, ['deploying', 'pending', 'starting'], true))) {
|
|
||||||
return 'deploying';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($statuses->contains(fn ($status) => in_array($status, ['stopped', 'inactive', 'paused'], true))) {
|
|
||||||
return 'warning';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($statuses->contains(fn ($status) => in_array($status, ['done', 'running', 'healthy'], true))) {
|
|
||||||
return 'done';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function formatServices(array $services): array
|
protected function formatServices(array $services): array
|
||||||
{
|
{
|
||||||
return collect($services)
|
return collect($services)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ namespace App\Filament\Widgets;
|
|||||||
|
|
||||||
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
class PlatformStatsWidget extends BaseWidget
|
class PlatformStatsWidget extends BaseWidget
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ class QueueHealthWidget extends Widget
|
|||||||
|
|
||||||
protected ?string $pollingInterval = '60s';
|
protected ?string $pollingInterval = '60s';
|
||||||
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
|
||||||
|
|
||||||
protected function getViewData(): array
|
protected function getViewData(): array
|
||||||
{
|
{
|
||||||
$snapshot = Cache::get('storage:queue-health:last');
|
$snapshot = Cache::get('storage:queue-health:last');
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use Filament\Widgets\LineChartWidget;
|
|||||||
|
|
||||||
class RevenueTrendWidget extends LineChartWidget
|
class RevenueTrendWidget extends LineChartWidget
|
||||||
{
|
{
|
||||||
|
|
||||||
protected static ?int $sort = 1;
|
protected static ?int $sort = 1;
|
||||||
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
protected int|string|array $columnSpan = 'full';
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Filament\Widgets;
|
namespace App\Filament\Widgets;
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Widgets\TableWidget as BaseWidget;
|
use Filament\Widgets\TableWidget as BaseWidget;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
|
||||||
class TopTenantsByUploads extends BaseWidget
|
class TopTenantsByUploads extends BaseWidget
|
||||||
{
|
{
|
||||||
@@ -14,7 +14,6 @@ class TopTenantsByUploads extends BaseWidget
|
|||||||
{
|
{
|
||||||
return __('admin.widgets.top_tenants_by_uploads.heading');
|
return __('admin.widgets.top_tenants_by_uploads.heading');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected ?string $pollingInterval = '60s';
|
protected ?string $pollingInterval = '60s';
|
||||||
|
|
||||||
public function table(Tables\Table $table): Tables\Table
|
public function table(Tables\Table $table): Tables\Table
|
||||||
@@ -34,3 +33,4 @@ class TopTenantsByUploads extends BaseWidget
|
|||||||
->paginated(false);
|
->paginated(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Routing\Controller as BaseController;
|
use Illuminate\Routing\Controller as BaseController;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||||
|
|
||||||
class QrController extends BaseController
|
class QrController extends BaseController
|
||||||
@@ -15,7 +15,7 @@ class QrController extends BaseController
|
|||||||
return response('missing data', 400);
|
return response('missing data', 400);
|
||||||
}
|
}
|
||||||
$png = QrCode::format('png')->size(300)->generate($data);
|
$png = QrCode::format('png')->size(300)->generate($data);
|
||||||
|
|
||||||
return response($png, 200, ['Content-Type' => 'image/png']);
|
return response($png, 200, ['Content-Type' => 'image/png']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -185,57 +185,6 @@ class EventPublicController extends BaseController
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$deviceId = (string) $request->header('X-Device-Id', $request->input('device_id', ''));
|
|
||||||
$deviceId = $deviceId !== '' ? $deviceId : null;
|
|
||||||
|
|
||||||
if ($event->id ?? null) {
|
|
||||||
$eventModel = Event::with(['tenant', 'eventPackage.package', 'eventPackages.package'])->find($event->id);
|
|
||||||
if ($eventModel && $eventModel->tenant) {
|
|
||||||
$eventPackage = $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload(
|
|
||||||
$eventModel->tenant,
|
|
||||||
$eventModel->id,
|
|
||||||
$eventModel
|
|
||||||
);
|
|
||||||
$maxGuests = $eventPackage?->effectiveGuestLimit();
|
|
||||||
|
|
||||||
if ($eventPackage && $maxGuests !== null) {
|
|
||||||
$grace = (int) config('package-limits.guest_grace', 10);
|
|
||||||
$hardLimit = $maxGuests + max(0, $grace);
|
|
||||||
$usedGuests = (int) $eventPackage->used_guests;
|
|
||||||
$isReturningGuest = $this->joinTokenService->hasSeenGuest($eventModel->id, $deviceId, $request->ip());
|
|
||||||
|
|
||||||
if ($usedGuests >= $hardLimit && ! $isReturningGuest) {
|
|
||||||
$this->recordTokenEvent(
|
|
||||||
$joinToken,
|
|
||||||
$request,
|
|
||||||
'guest_limit_exceeded',
|
|
||||||
[
|
|
||||||
'event_id' => $eventModel->id,
|
|
||||||
'used' => $usedGuests,
|
|
||||||
'limit' => $maxGuests,
|
|
||||||
'hard_limit' => $hardLimit,
|
|
||||||
],
|
|
||||||
$token,
|
|
||||||
Response::HTTP_PAYMENT_REQUIRED
|
|
||||||
);
|
|
||||||
|
|
||||||
return ApiError::response(
|
|
||||||
'guest_limit_exceeded',
|
|
||||||
__('api.packages.guest_limit_exceeded.title'),
|
|
||||||
__('api.packages.guest_limit_exceeded.message'),
|
|
||||||
Response::HTTP_PAYMENT_REQUIRED,
|
|
||||||
[
|
|
||||||
'event_id' => $eventModel->id,
|
|
||||||
'used' => $usedGuests,
|
|
||||||
'limit' => $maxGuests,
|
|
||||||
'hard_limit' => $hardLimit,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RateLimiter::clear($rateLimiterKey);
|
RateLimiter::clear($rateLimiterKey);
|
||||||
|
|
||||||
if (isset($event->status)) {
|
if (isset($event->status)) {
|
||||||
@@ -1076,10 +1025,10 @@ class EventPublicController extends BaseController
|
|||||||
private function resolveBrandingPayload(Event $event): array
|
private function resolveBrandingPayload(Event $event): array
|
||||||
{
|
{
|
||||||
$defaults = [
|
$defaults = [
|
||||||
'primary' => '#FF5A5F',
|
'primary' => '#f43f5e',
|
||||||
'secondary' => '#FFF8F5',
|
'secondary' => '#fb7185',
|
||||||
'background' => '#FFF8F5',
|
'background' => '#ffffff',
|
||||||
'surface' => '#FFF8F5',
|
'surface' => '#ffffff',
|
||||||
'font' => null,
|
'font' => null,
|
||||||
'size' => 'm',
|
'size' => 'm',
|
||||||
'logo_position' => 'left',
|
'logo_position' => 'left',
|
||||||
@@ -1093,8 +1042,12 @@ class EventPublicController extends BaseController
|
|||||||
$brandingAllowed = $this->determineBrandingAllowed($event);
|
$brandingAllowed = $this->determineBrandingAllowed($event);
|
||||||
|
|
||||||
$eventBranding = $brandingAllowed ? Arr::get($event->settings, 'branding', []) : [];
|
$eventBranding = $brandingAllowed ? Arr::get($event->settings, 'branding', []) : [];
|
||||||
|
$tenantBranding = $brandingAllowed ? Arr::get($event->tenant?->settings, 'branding', []) : [];
|
||||||
|
|
||||||
$useDefault = (bool) Arr::get($eventBranding, 'use_default_branding', Arr::get($eventBranding, 'use_default', false));
|
$useDefault = (bool) Arr::get($eventBranding, 'use_default_branding', Arr::get($eventBranding, 'use_default', false));
|
||||||
$sources = $brandingAllowed ? [$eventBranding] : [[]];
|
$sources = $brandingAllowed
|
||||||
|
? ($useDefault ? [$tenantBranding] : [$eventBranding, $tenantBranding])
|
||||||
|
: [[]];
|
||||||
|
|
||||||
$primary = $this->normalizeHexColor(
|
$primary = $this->normalizeHexColor(
|
||||||
$this->firstStringFromSources($sources, ['palette.primary', 'primary_color']),
|
$this->firstStringFromSources($sources, ['palette.primary', 'primary_color']),
|
||||||
@@ -1345,7 +1298,7 @@ class EventPublicController extends BaseController
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$diskName = 'public';
|
$diskName = config('filesystems.default', 'public');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$storage = Storage::disk($diskName);
|
$storage = Storage::disk($diskName);
|
||||||
@@ -1953,9 +1906,7 @@ class EventPublicController extends BaseController
|
|||||||
$policy = $this->guestPolicy();
|
$policy = $this->guestPolicy();
|
||||||
|
|
||||||
if ($joinToken) {
|
if ($joinToken) {
|
||||||
$deviceId = (string) $request->header('X-Device-Id', $request->input('device_id', ''));
|
$this->joinTokenService->incrementUsage($joinToken);
|
||||||
$deviceId = $deviceId !== '' ? $deviceId : null;
|
|
||||||
$this->joinTokenService->incrementUsage($joinToken, $deviceId, $request->ip());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$demoReadOnly = (bool) Arr::get($joinToken?->metadata ?? [], 'demo_read_only', false);
|
$demoReadOnly = (bool) Arr::get($joinToken?->metadata ?? [], 'demo_read_only', false);
|
||||||
@@ -2970,12 +2921,6 @@ class EventPublicController extends BaseController
|
|||||||
$policy = $this->guestPolicy();
|
$policy = $this->guestPolicy();
|
||||||
$uploadVisibility = Arr::get($eventModel->settings ?? [], 'guest_upload_visibility', $policy->guest_upload_visibility);
|
$uploadVisibility = Arr::get($eventModel->settings ?? [], 'guest_upload_visibility', $policy->guest_upload_visibility);
|
||||||
$autoApproveUploads = $uploadVisibility === 'immediate';
|
$autoApproveUploads = $uploadVisibility === 'immediate';
|
||||||
$controlRoom = Arr::get($eventModel->settings ?? [], 'control_room', []);
|
|
||||||
$controlRoom = is_array($controlRoom) ? $controlRoom : [];
|
|
||||||
$autoAddApprovedToLiveSetting = (bool) Arr::get($controlRoom, 'auto_add_approved_to_live', true);
|
|
||||||
$trustedUploaders = Arr::get($controlRoom, 'trusted_uploaders', []);
|
|
||||||
$forceReviewUploaders = Arr::get($controlRoom, 'force_review_uploaders', []);
|
|
||||||
$autoAddApprovedToLiveDefault = $autoAddApprovedToLiveSetting || $autoApproveUploads;
|
|
||||||
|
|
||||||
$tenantModel = $eventModel->tenant;
|
$tenantModel = $eventModel->tenant;
|
||||||
|
|
||||||
@@ -3008,34 +2953,6 @@ class EventPublicController extends BaseController
|
|||||||
->resolveEventPackageForPhotoUpload($tenantModel, $eventId, $eventModel);
|
->resolveEventPackageForPhotoUpload($tenantModel, $eventId, $eventModel);
|
||||||
|
|
||||||
$deviceId = $this->resolveDeviceIdentifier($request);
|
$deviceId = $this->resolveDeviceIdentifier($request);
|
||||||
$deviceHasRule = static function (array $entries, string $deviceId): bool {
|
|
||||||
foreach ($entries as $entry) {
|
|
||||||
if (! is_array($entry)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$candidate = $entry['device_id'] ?? null;
|
|
||||||
if (is_string($candidate) && $candidate === $deviceId) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
$deviceHasRules = $deviceId !== 'anonymous';
|
|
||||||
$isForceReviewUploader = $deviceHasRules && is_array($forceReviewUploaders)
|
|
||||||
? $deviceHasRule($forceReviewUploaders, $deviceId)
|
|
||||||
: false;
|
|
||||||
$isTrustedUploader = $deviceHasRules && is_array($trustedUploaders)
|
|
||||||
? $deviceHasRule($trustedUploaders, $deviceId)
|
|
||||||
: false;
|
|
||||||
|
|
||||||
if ($isForceReviewUploader) {
|
|
||||||
$autoApproveUploads = false;
|
|
||||||
} elseif ($isTrustedUploader) {
|
|
||||||
$autoApproveUploads = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$autoAddApprovedToLive = $autoAddApprovedToLiveDefault && $autoApproveUploads;
|
|
||||||
|
|
||||||
$deviceLimit = max(0, (int) ($policy->per_device_upload_limit ?? 50));
|
$deviceLimit = max(0, (int) ($policy->per_device_upload_limit ?? 50));
|
||||||
$deviceCount = DB::table('photos')->where('event_id', $eventId)->where('guest_name', $deviceId)->count();
|
$deviceCount = DB::table('photos')->where('event_id', $eventId)->where('guest_name', $deviceId)->count();
|
||||||
@@ -3120,21 +3037,10 @@ class EventPublicController extends BaseController
|
|||||||
$liveApprovedAt = null;
|
$liveApprovedAt = null;
|
||||||
$liveReviewedAt = null;
|
$liveReviewedAt = null;
|
||||||
$liveStatus = PhotoLiveStatus::NONE->value;
|
$liveStatus = PhotoLiveStatus::NONE->value;
|
||||||
$securityMeta = $isForceReviewUploader
|
|
||||||
? [
|
|
||||||
'manual_review' => true,
|
|
||||||
'manual_review_reason' => 'force_review_device',
|
|
||||||
]
|
|
||||||
: null;
|
|
||||||
$securityMetaValue = $securityMeta ? json_encode($securityMeta) : null;
|
|
||||||
|
|
||||||
if ($liveOptIn || $autoAddApprovedToLive) {
|
if ($liveOptIn) {
|
||||||
$liveSubmittedAt = now();
|
$liveSubmittedAt = now();
|
||||||
if ($autoAddApprovedToLive) {
|
if ($liveModerationMode === 'off') {
|
||||||
$liveStatus = PhotoLiveStatus::APPROVED->value;
|
|
||||||
$liveApprovedAt = $liveSubmittedAt;
|
|
||||||
$liveReviewedAt = $liveSubmittedAt;
|
|
||||||
} elseif ($liveModerationMode === 'off') {
|
|
||||||
$liveStatus = PhotoLiveStatus::APPROVED->value;
|
$liveStatus = PhotoLiveStatus::APPROVED->value;
|
||||||
$liveApprovedAt = $liveSubmittedAt;
|
$liveApprovedAt = $liveSubmittedAt;
|
||||||
$liveReviewedAt = $liveSubmittedAt;
|
$liveReviewedAt = $liveSubmittedAt;
|
||||||
@@ -3142,12 +3048,6 @@ class EventPublicController extends BaseController
|
|||||||
$liveStatus = PhotoLiveStatus::PENDING->value;
|
$liveStatus = PhotoLiveStatus::PENDING->value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($isForceReviewUploader) {
|
|
||||||
$liveStatus = PhotoLiveStatus::REJECTED->value;
|
|
||||||
$liveSubmittedAt = null;
|
|
||||||
$liveApprovedAt = null;
|
|
||||||
$liveReviewedAt = now();
|
|
||||||
}
|
|
||||||
|
|
||||||
$photoId = DB::table('photos')->insertGetId([
|
$photoId = DB::table('photos')->insertGetId([
|
||||||
'event_id' => $eventId,
|
'event_id' => $eventId,
|
||||||
@@ -3171,7 +3071,6 @@ class EventPublicController extends BaseController
|
|||||||
'emotion_id' => $this->resolveEmotionId($validated, $eventId),
|
'emotion_id' => $this->resolveEmotionId($validated, $eventId),
|
||||||
'is_featured' => 0,
|
'is_featured' => 0,
|
||||||
'metadata' => null,
|
'metadata' => null,
|
||||||
'security_meta' => $securityMetaValue,
|
|
||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -26,11 +26,11 @@ class LegalController extends BaseController
|
|||||||
'allow_unsafe_links' => false,
|
'allow_unsafe_links' => false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$environment->addExtension(new CommonMarkCoreExtension);
|
$environment->addExtension(new CommonMarkCoreExtension());
|
||||||
$environment->addExtension(new TableExtension);
|
$environment->addExtension(new TableExtension());
|
||||||
$environment->addExtension(new AutolinkExtension);
|
$environment->addExtension(new AutolinkExtension());
|
||||||
$environment->addExtension(new StrikethroughExtension);
|
$environment->addExtension(new StrikethroughExtension());
|
||||||
$environment->addExtension(new TaskListExtension);
|
$environment->addExtension(new TaskListExtension());
|
||||||
|
|
||||||
$this->markdown = new MarkdownConverter($environment);
|
$this->markdown = new MarkdownConverter($environment);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,13 +277,13 @@ class PackageController extends Controller
|
|||||||
'purchased_at' => now(),
|
'purchased_at' => now(),
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
// Partner / reseller Event-Kontingent package
|
// Reseller subscription
|
||||||
\App\Models\TenantPackage::create([
|
\App\Models\TenantPackage::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
'price' => $package->price,
|
'price' => $package->price,
|
||||||
'purchased_at' => now(),
|
'purchased_at' => now(),
|
||||||
'expires_at' => null,
|
'expires_at' => now()->addYear(),
|
||||||
'active' => true,
|
'active' => true,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ namespace App\Http\Controllers\Api;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Photobooth\PhotoboothConnectRedeemRequest;
|
use App\Http\Requests\Photobooth\PhotoboothConnectRedeemRequest;
|
||||||
use App\Models\Event;
|
|
||||||
use App\Services\Photobooth\PhotoboothConnectCodeService;
|
use App\Services\Photobooth\PhotoboothConnectCodeService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
@@ -34,8 +33,7 @@ class PhotoboothConnectController extends Controller
|
|||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => [
|
'data' => [
|
||||||
'event_name' => $this->resolveEventName($event),
|
'upload_url' => route('api.v1.photobooth.sparkbooth.upload'),
|
||||||
'upload_url' => route('api.v1.photobooth.upload'),
|
|
||||||
'username' => $setting->username,
|
'username' => $setting->username,
|
||||||
'password' => $setting->password,
|
'password' => $setting->password,
|
||||||
'expires_at' => optional($setting->expires_at)->toIso8601String(),
|
'expires_at' => optional($setting->expires_at)->toIso8601String(),
|
||||||
@@ -44,27 +42,4 @@ class PhotoboothConnectController extends Controller
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveEventName(?Event $event): ?string
|
|
||||||
{
|
|
||||||
if (! $event) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$name = $event->name;
|
|
||||||
|
|
||||||
if (is_string($name) && trim($name) !== '') {
|
|
||||||
return $name;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_array($name)) {
|
|
||||||
foreach ($name as $value) {
|
|
||||||
if (is_string($value) && trim($value) !== '') {
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $event->slug ?: null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\Support;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Http\Requests\Support\SupportGuestPolicyRequest;
|
|
||||||
use App\Models\GuestPolicySetting;
|
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
|
||||||
use App\Support\SupportApiAuthorizer;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
|
|
||||||
class SupportGuestPolicyController extends Controller
|
|
||||||
{
|
|
||||||
public function show(): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = SupportApiAuthorizer::authorizeAbilities(request(), ['support:settings'], 'settings')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$settings = GuestPolicySetting::current();
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => $settings,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(SupportGuestPolicyRequest $request): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = SupportApiAuthorizer::authorizeAbilities($request, ['support:settings'], 'settings')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$settings = GuestPolicySetting::query()->firstOrNew(['id' => 1]);
|
|
||||||
|
|
||||||
$settings->fill($request->validated());
|
|
||||||
$settings->save();
|
|
||||||
|
|
||||||
$changed = $settings->getChanges();
|
|
||||||
|
|
||||||
if ($changed !== []) {
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'guest_policy.updated',
|
|
||||||
$settings,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => $settings->refresh(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,401 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\Support;
|
|
||||||
|
|
||||||
use App\Enums\DataExportScope;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Http\Requests\Support\Resources\SupportResourceFormRequest;
|
|
||||||
use App\Http\Requests\Support\SupportResourceRequest;
|
|
||||||
use App\Jobs\GenerateDataExport;
|
|
||||||
use App\Models\DataExport;
|
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
|
||||||
use App\Support\ApiError;
|
|
||||||
use App\Support\SupportApiAuthorizer;
|
|
||||||
use App\Support\SupportApiRegistry;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Illuminate\Support\Facades\Validator;
|
|
||||||
|
|
||||||
class SupportResourceController extends Controller
|
|
||||||
{
|
|
||||||
public function index(Request $request, string $resource): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = SupportApiAuthorizer::authorizeResource($request, $resource, 'read')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$config = SupportApiRegistry::get($resource);
|
|
||||||
if (! $config) {
|
|
||||||
return $this->resourceNotFoundResponse($resource);
|
|
||||||
}
|
|
||||||
|
|
||||||
$modelClass = $config['model'];
|
|
||||||
/** @var Builder $query */
|
|
||||||
$query = $modelClass::query();
|
|
||||||
|
|
||||||
$relations = SupportApiRegistry::withRelations($resource);
|
|
||||||
if ($relations !== []) {
|
|
||||||
$query->with($relations);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->applySearch($request, $query, $resource);
|
|
||||||
$this->applySorting($request, $query, $resource);
|
|
||||||
|
|
||||||
$perPage = $this->resolvePerPage($request);
|
|
||||||
$paginator = $query->paginate($perPage);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => $paginator->items(),
|
|
||||||
'meta' => [
|
|
||||||
'page' => $paginator->currentPage(),
|
|
||||||
'per_page' => $paginator->perPage(),
|
|
||||||
'total' => $paginator->total(),
|
|
||||||
'last_page' => $paginator->lastPage(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(Request $request, string $resource, string $record): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = SupportApiAuthorizer::authorizeResource($request, $resource, 'read')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$model = $this->resolveRecord($resource, $record);
|
|
||||||
|
|
||||||
if (! $model) {
|
|
||||||
return $this->resourceNotFoundResponse($resource, $record);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => $model,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(SupportResourceRequest $request, string $resource): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = SupportApiAuthorizer::authorizeAnyAbility($request, SupportApiRegistry::abilitiesFor($resource, 'write'), 'write')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! SupportApiRegistry::allowsMutation($resource, 'create')) {
|
|
||||||
return $this->mutationNotAllowedResponse($resource, 'create');
|
|
||||||
}
|
|
||||||
|
|
||||||
$config = SupportApiRegistry::get($resource);
|
|
||||||
if (! $config) {
|
|
||||||
return $this->resourceNotFoundResponse($resource);
|
|
||||||
}
|
|
||||||
|
|
||||||
$modelClass = $config['model'];
|
|
||||||
/** @var Model $model */
|
|
||||||
$model = new $modelClass;
|
|
||||||
|
|
||||||
$payload = $this->validatedPayload($request, $resource, 'create', $model);
|
|
||||||
|
|
||||||
if ($payload instanceof JsonResponse) {
|
|
||||||
return $payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($payload === []) {
|
|
||||||
return $this->emptyPayloadResponse($resource);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($resource === 'data-exports') {
|
|
||||||
$payload = $this->normalizeDataExportPayload($request, $payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
$record = $modelClass::query()->create($payload);
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
SupportApiRegistry::auditAction($resource, 'created'),
|
|
||||||
$record,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata($payload),
|
|
||||||
actor: $request->user(),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($resource === 'data-exports') {
|
|
||||||
GenerateDataExport::dispatch($record->id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => $record,
|
|
||||||
], 201);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(SupportResourceRequest $request, string $resource, string $record): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = SupportApiAuthorizer::authorizeAnyAbility($request, SupportApiRegistry::abilitiesFor($resource, 'write'), 'write')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! SupportApiRegistry::allowsMutation($resource, 'update')) {
|
|
||||||
return $this->mutationNotAllowedResponse($resource, 'update');
|
|
||||||
}
|
|
||||||
|
|
||||||
$model = $this->resolveRecord($resource, $record);
|
|
||||||
|
|
||||||
if (! $model) {
|
|
||||||
return $this->resourceNotFoundResponse($resource, $record);
|
|
||||||
}
|
|
||||||
|
|
||||||
$payload = $this->validatedPayload($request, $resource, 'update', $model);
|
|
||||||
|
|
||||||
if ($payload instanceof JsonResponse) {
|
|
||||||
return $payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($payload === []) {
|
|
||||||
return $this->emptyPayloadResponse($resource);
|
|
||||||
}
|
|
||||||
|
|
||||||
$model->fill($payload);
|
|
||||||
$model->save();
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
SupportApiRegistry::auditAction($resource, 'updated'),
|
|
||||||
$model,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata($payload),
|
|
||||||
actor: $request->user(),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => $model->refresh(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(Request $request, string $resource, string $record): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = SupportApiAuthorizer::authorizeAnyAbility($request, SupportApiRegistry::abilitiesFor($resource, 'write'), 'write')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! SupportApiRegistry::allowsMutation($resource, 'delete')) {
|
|
||||||
return $this->mutationNotAllowedResponse($resource, 'delete');
|
|
||||||
}
|
|
||||||
|
|
||||||
$model = $this->resolveRecord($resource, $record);
|
|
||||||
|
|
||||||
if (! $model) {
|
|
||||||
return $this->resourceNotFoundResponse($resource, $record);
|
|
||||||
}
|
|
||||||
|
|
||||||
$model->delete();
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
SupportApiRegistry::auditAction($resource, 'deleted'),
|
|
||||||
$model,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata([]),
|
|
||||||
actor: $request->user(),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveRecord(string $resource, string $record): ?Model
|
|
||||||
{
|
|
||||||
$config = SupportApiRegistry::get($resource);
|
|
||||||
|
|
||||||
if (! $config) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$modelClass = $config['model'];
|
|
||||||
|
|
||||||
$query = $modelClass::query();
|
|
||||||
|
|
||||||
if (is_numeric($record)) {
|
|
||||||
return $query->find($record);
|
|
||||||
}
|
|
||||||
|
|
||||||
$keyName = (new $modelClass)->getKeyName();
|
|
||||||
|
|
||||||
return $query->where($keyName, $record)->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function validatedPayload(SupportResourceRequest $request, string $resource, string $action, Model $model): array|JsonResponse
|
|
||||||
{
|
|
||||||
$payload = $request->validated('data');
|
|
||||||
|
|
||||||
if (! is_array($payload)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$validationClass = SupportApiRegistry::validationClass($resource, $action);
|
|
||||||
|
|
||||||
if ($validationClass && is_subclass_of($validationClass, SupportResourceFormRequest::class)) {
|
|
||||||
$allowedFields = $validationClass::allowedFields($action);
|
|
||||||
|
|
||||||
if ($allowedFields !== []) {
|
|
||||||
$unexpected = array_diff(array_keys($payload), $allowedFields);
|
|
||||||
if ($unexpected !== []) {
|
|
||||||
return $this->invalidFieldResponse($resource, $unexpected);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$rules = $validationClass::rulesFor($action, $model);
|
|
||||||
if ($rules !== []) {
|
|
||||||
$payload = Validator::make($payload, $rules)->validate();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($allowedFields !== []) {
|
|
||||||
$payload = Arr::only($payload, $allowedFields);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$fillable = $model->getFillable();
|
|
||||||
|
|
||||||
if ($fillable === [] && method_exists($model, 'getGuarded') && $model->getGuarded() !== ['*']) {
|
|
||||||
$columns = Schema::getColumnListing($model->getTable());
|
|
||||||
|
|
||||||
return Arr::only($payload, $columns);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($fillable === []) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return Arr::only($payload, $fillable);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function applySearch(Request $request, Builder $query, string $resource): void
|
|
||||||
{
|
|
||||||
$term = $request->string('search')->trim()->value();
|
|
||||||
|
|
||||||
if ($term === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$fields = SupportApiRegistry::searchFields($resource);
|
|
||||||
|
|
||||||
if ($fields === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$columns = Schema::getColumnListing($query->getModel()->getTable());
|
|
||||||
$fields = array_values(array_intersect($fields, $columns));
|
|
||||||
|
|
||||||
if ($fields === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$query->where(function (Builder $builder) use ($fields, $term): void {
|
|
||||||
foreach ($fields as $field) {
|
|
||||||
if ($field === 'id' && is_numeric($term)) {
|
|
||||||
$builder->orWhere($field, (int) $term);
|
|
||||||
} else {
|
|
||||||
$builder->orWhere($field, 'like', "%{$term}%");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private function applySorting(Request $request, Builder $query, string $resource): void
|
|
||||||
{
|
|
||||||
$sort = $request->string('sort')->trim()->value();
|
|
||||||
|
|
||||||
if ($sort === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$direction = 'asc';
|
|
||||||
$field = $sort;
|
|
||||||
|
|
||||||
if (str_starts_with($sort, '-')) {
|
|
||||||
$direction = 'desc';
|
|
||||||
$field = ltrim($sort, '-');
|
|
||||||
}
|
|
||||||
|
|
||||||
$allowed = SupportApiRegistry::searchFields($resource);
|
|
||||||
$allowed[] = 'id';
|
|
||||||
|
|
||||||
$columns = Schema::getColumnListing($query->getModel()->getTable());
|
|
||||||
$allowed = array_values(array_intersect($allowed, $columns));
|
|
||||||
|
|
||||||
if (! in_array($field, $allowed, true)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$query->orderBy($field, $direction);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolvePerPage(Request $request): int
|
|
||||||
{
|
|
||||||
$default = (int) config('support-api.pagination.default_per_page', 50);
|
|
||||||
$max = (int) config('support-api.pagination.max_per_page', 200);
|
|
||||||
|
|
||||||
$perPage = (int) $request->input('per_page', $default);
|
|
||||||
|
|
||||||
if ($perPage < 1) {
|
|
||||||
$perPage = $default;
|
|
||||||
}
|
|
||||||
|
|
||||||
return min($perPage, $max);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function mutationNotAllowedResponse(string $resource, string $action): JsonResponse
|
|
||||||
{
|
|
||||||
return ApiError::response(
|
|
||||||
'support_mutation_not_allowed',
|
|
||||||
'Mutation Not Allowed',
|
|
||||||
"{$resource} does not allow {$action} operations in support API.",
|
|
||||||
403
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function emptyPayloadResponse(string $resource): JsonResponse
|
|
||||||
{
|
|
||||||
return ApiError::response(
|
|
||||||
'support_invalid_payload',
|
|
||||||
'Invalid Payload',
|
|
||||||
"No mutable fields provided for {$resource}.",
|
|
||||||
422
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function invalidFieldResponse(string $resource, array $fields): JsonResponse
|
|
||||||
{
|
|
||||||
return ApiError::response(
|
|
||||||
'support_invalid_fields',
|
|
||||||
'Invalid Fields',
|
|
||||||
"Unsupported fields provided for {$resource}.",
|
|
||||||
422,
|
|
||||||
[
|
|
||||||
'fields' => array_values($fields),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resourceNotFoundResponse(string $resource, ?string $record = null): JsonResponse
|
|
||||||
{
|
|
||||||
$message = $record
|
|
||||||
? "{$resource} record not found."
|
|
||||||
: "Support resource {$resource} is not registered.";
|
|
||||||
|
|
||||||
return ApiError::response(
|
|
||||||
'support_resource_not_found',
|
|
||||||
'Not Found',
|
|
||||||
$message,
|
|
||||||
404
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeDataExportPayload(Request $request, array $payload): array
|
|
||||||
{
|
|
||||||
$payload['user_id'] = $request->user()?->id;
|
|
||||||
$payload['status'] = DataExport::STATUS_PENDING;
|
|
||||||
|
|
||||||
if (($payload['scope'] ?? null) !== DataExportScope::EVENT->value) {
|
|
||||||
$payload['event_id'] = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $payload;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,411 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\Support;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Http\Requests\Support\Tenant\SupportTenantAddPackageRequest;
|
|
||||||
use App\Http\Requests\Support\Tenant\SupportTenantScheduleDeletionRequest;
|
|
||||||
use App\Http\Requests\Support\Tenant\SupportTenantSetGracePeriodRequest;
|
|
||||||
use App\Http\Requests\Support\Tenant\SupportTenantUpdateLimitsRequest;
|
|
||||||
use App\Http\Requests\Support\Tenant\SupportTenantUpdateSubscriptionRequest;
|
|
||||||
use App\Jobs\AnonymizeAccount;
|
|
||||||
use App\Models\Package;
|
|
||||||
use App\Models\PackagePurchase;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantPackage;
|
|
||||||
use App\Notifications\InactiveTenantDeletionWarning;
|
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
|
||||||
use App\Services\Tenant\TenantLifecycleLogger;
|
|
||||||
use App\Support\SupportApiAuthorizer;
|
|
||||||
use Carbon\Carbon;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Support\Facades\Notification as NotificationFacade;
|
|
||||||
|
|
||||||
class SupportTenantActionsController extends Controller
|
|
||||||
{
|
|
||||||
public function activate(Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$updated = $tenant->update(['is_active' => true]);
|
|
||||||
|
|
||||||
app(TenantLifecycleLogger::class)->record(
|
|
||||||
$tenant,
|
|
||||||
'activated',
|
|
||||||
actor: auth()->user()
|
|
||||||
);
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'tenant.activated',
|
|
||||||
$tenant,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata(['is_active']),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['ok' => $updated]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deactivate(Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$updated = $tenant->update(['is_active' => false]);
|
|
||||||
|
|
||||||
app(TenantLifecycleLogger::class)->record(
|
|
||||||
$tenant,
|
|
||||||
'deactivated',
|
|
||||||
actor: auth()->user()
|
|
||||||
);
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'tenant.deactivated',
|
|
||||||
$tenant,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata(['is_active']),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['ok' => $updated]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function suspend(Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$updated = $tenant->update(['is_suspended' => true]);
|
|
||||||
|
|
||||||
app(TenantLifecycleLogger::class)->record(
|
|
||||||
$tenant,
|
|
||||||
'suspended',
|
|
||||||
actor: auth()->user()
|
|
||||||
);
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'tenant.suspended',
|
|
||||||
$tenant,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata(['is_suspended']),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['ok' => $updated]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function unsuspend(Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$updated = $tenant->update(['is_suspended' => false]);
|
|
||||||
|
|
||||||
app(TenantLifecycleLogger::class)->record(
|
|
||||||
$tenant,
|
|
||||||
'unsuspended',
|
|
||||||
actor: auth()->user()
|
|
||||||
);
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'tenant.unsuspended',
|
|
||||||
$tenant,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata(['is_suspended']),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['ok' => $updated]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function scheduleDeletion(SupportTenantScheduleDeletionRequest $request, Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$plannedDeletion = Carbon::parse($request->string('pending_deletion_at')->value());
|
|
||||||
$update = [
|
|
||||||
'pending_deletion_at' => $plannedDeletion,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($request->boolean('send_warning', true)) {
|
|
||||||
$email = $tenant->contact_email
|
|
||||||
?? $tenant->email
|
|
||||||
?? $tenant->user?->email;
|
|
||||||
|
|
||||||
if ($email) {
|
|
||||||
NotificationFacade::route('mail', $email)
|
|
||||||
->notify(new InactiveTenantDeletionWarning($tenant, $plannedDeletion));
|
|
||||||
$update['deletion_warning_sent_at'] = now();
|
|
||||||
} else {
|
|
||||||
Notification::make()
|
|
||||||
->danger()
|
|
||||||
->title(__('admin.tenants.actions.send_warning_missing_title'))
|
|
||||||
->body(__('admin.tenants.actions.send_warning_missing_body'))
|
|
||||||
->send();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant->forceFill($update)->save();
|
|
||||||
|
|
||||||
app(TenantLifecycleLogger::class)->record(
|
|
||||||
$tenant,
|
|
||||||
'deletion_scheduled',
|
|
||||||
[
|
|
||||||
'pending_deletion_at' => $plannedDeletion->toDateTimeString(),
|
|
||||||
'send_warning' => $request->boolean('send_warning', true),
|
|
||||||
],
|
|
||||||
auth()->user()
|
|
||||||
);
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'tenant.deletion_scheduled',
|
|
||||||
$tenant,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function cancelDeletion(Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$previous = $tenant->pending_deletion_at?->toDateTimeString();
|
|
||||||
|
|
||||||
$tenant->forceFill([
|
|
||||||
'pending_deletion_at' => null,
|
|
||||||
'deletion_warning_sent_at' => null,
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
app(TenantLifecycleLogger::class)->record(
|
|
||||||
$tenant,
|
|
||||||
'deletion_cancelled',
|
|
||||||
['pending_deletion_at' => $previous],
|
|
||||||
auth()->user()
|
|
||||||
);
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'tenant.deletion_cancelled',
|
|
||||||
$tenant,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata(['pending_deletion_at', 'deletion_warning_sent_at']),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function anonymize(Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
AnonymizeAccount::dispatch(null, $tenant->id);
|
|
||||||
|
|
||||||
app(TenantLifecycleLogger::class)->record(
|
|
||||||
$tenant,
|
|
||||||
'anonymize_requested',
|
|
||||||
actor: auth()->user()
|
|
||||||
);
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'tenant.anonymize_requested',
|
|
||||||
$tenant,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata([]),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function addPackage(SupportTenantAddPackageRequest $request, Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$package = Package::query()->find($request->integer('package_id'));
|
|
||||||
|
|
||||||
TenantPackage::query()->create([
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'package_id' => $request->integer('package_id'),
|
|
||||||
'expires_at' => $request->date('expires_at'),
|
|
||||||
'active' => true,
|
|
||||||
'price' => $package?->price ?? 0,
|
|
||||||
'reason' => $request->string('reason')->value(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
PackagePurchase::query()->create([
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'package_id' => $request->integer('package_id'),
|
|
||||||
'provider' => 'manual',
|
|
||||||
'provider_id' => 'manual',
|
|
||||||
'type' => 'reseller_subscription',
|
|
||||||
'price' => 0,
|
|
||||||
'metadata' => ['reason' => $request->string('reason')->value() ?: 'manual assignment'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'tenant.package_added',
|
|
||||||
$tenant,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateLimits(SupportTenantUpdateLimitsRequest $request, Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$before = [
|
|
||||||
'max_photos_per_event' => $tenant->max_photos_per_event,
|
|
||||||
'max_storage_mb' => $tenant->max_storage_mb,
|
|
||||||
];
|
|
||||||
|
|
||||||
$tenant->forceFill([
|
|
||||||
'max_photos_per_event' => $request->integer('max_photos_per_event'),
|
|
||||||
'max_storage_mb' => $request->integer('max_storage_mb'),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$after = [
|
|
||||||
'max_photos_per_event' => $tenant->max_photos_per_event,
|
|
||||||
'max_storage_mb' => $tenant->max_storage_mb,
|
|
||||||
];
|
|
||||||
|
|
||||||
app(TenantLifecycleLogger::class)->record(
|
|
||||||
$tenant,
|
|
||||||
'limits_updated',
|
|
||||||
[
|
|
||||||
'before' => $before,
|
|
||||||
'after' => $after,
|
|
||||||
'note' => $request->string('note')->value(),
|
|
||||||
],
|
|
||||||
auth()->user()
|
|
||||||
);
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'tenant.limits_updated',
|
|
||||||
$tenant,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateSubscriptionExpiresAt(SupportTenantUpdateSubscriptionRequest $request, Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$before = [
|
|
||||||
'subscription_expires_at' => optional($tenant->subscription_expires_at)->toDateTimeString(),
|
|
||||||
];
|
|
||||||
|
|
||||||
$tenant->forceFill([
|
|
||||||
'subscription_expires_at' => $request->date('subscription_expires_at'),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$after = [
|
|
||||||
'subscription_expires_at' => optional($tenant->subscription_expires_at)->toDateTimeString(),
|
|
||||||
];
|
|
||||||
|
|
||||||
app(TenantLifecycleLogger::class)->record(
|
|
||||||
$tenant,
|
|
||||||
'subscription_expires_at_updated',
|
|
||||||
[
|
|
||||||
'before' => $before,
|
|
||||||
'after' => $after,
|
|
||||||
'note' => $request->string('note')->value(),
|
|
||||||
],
|
|
||||||
auth()->user()
|
|
||||||
);
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'tenant.subscription_expires_at_updated',
|
|
||||||
$tenant,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setGracePeriod(SupportTenantSetGracePeriodRequest $request, Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant->forceFill([
|
|
||||||
'grace_period_ends_at' => $request->date('grace_period_ends_at'),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
app(TenantLifecycleLogger::class)->record(
|
|
||||||
$tenant,
|
|
||||||
'grace_period_set',
|
|
||||||
[
|
|
||||||
'grace_period_ends_at' => optional($tenant->grace_period_ends_at)->toDateTimeString(),
|
|
||||||
'note' => $request->string('note')->value(),
|
|
||||||
],
|
|
||||||
auth()->user()
|
|
||||||
);
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'tenant.grace_period_set',
|
|
||||||
$tenant,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function clearGracePeriod(Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$previous = $tenant->grace_period_ends_at?->toDateTimeString();
|
|
||||||
|
|
||||||
$tenant->forceFill([
|
|
||||||
'grace_period_ends_at' => null,
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
app(TenantLifecycleLogger::class)->record(
|
|
||||||
$tenant,
|
|
||||||
'grace_period_cleared',
|
|
||||||
[
|
|
||||||
'grace_period_ends_at' => $previous,
|
|
||||||
],
|
|
||||||
auth()->user()
|
|
||||||
);
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'tenant.grace_period_cleared',
|
|
||||||
$tenant,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata(['grace_period_ends_at']),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function authorizeAction(string $resource, string $action): ?JsonResponse
|
|
||||||
{
|
|
||||||
return SupportApiAuthorizer::authorizeResource(request(), $resource, $action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\Support;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Http\Requests\Support\SupportTokenRequest;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
|
|
||||||
class SupportTokenController extends Controller
|
|
||||||
{
|
|
||||||
public function store(SupportTokenRequest $request): JsonResponse
|
|
||||||
{
|
|
||||||
$credentials = $request->credentials();
|
|
||||||
|
|
||||||
$query = User::query();
|
|
||||||
|
|
||||||
if (isset($credentials['email'])) {
|
|
||||||
$query->where('email', $credentials['email']);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($credentials['username'])) {
|
|
||||||
$query->where('username', $credentials['username']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var User|null $user */
|
|
||||||
$user = $query->first();
|
|
||||||
|
|
||||||
if (! $user || ! Hash::check($credentials['password'], (string) $user->password)) {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'login' => [trans('auth.failed')],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->isSuperAdmin()) {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'login' => [trans('auth.not_authorized')],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tokenConfig = config('support-api.token');
|
|
||||||
$defaultAbilities = $tokenConfig['default_abilities'] ?? [];
|
|
||||||
$abilities = $credentials['abilities'] ?? $defaultAbilities;
|
|
||||||
|
|
||||||
if ($abilities !== $defaultAbilities) {
|
|
||||||
$abilities = array_values(array_intersect($abilities, $defaultAbilities));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! in_array('support-admin', $abilities, true)) {
|
|
||||||
$abilities[] = 'support-admin';
|
|
||||||
}
|
|
||||||
|
|
||||||
$tokenName = (string) ($tokenConfig['name'] ?? 'support-api');
|
|
||||||
|
|
||||||
$user->tokens()->where('name', $tokenName)->delete();
|
|
||||||
|
|
||||||
$token = $user->createToken($tokenName, $abilities);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'token' => $token->plainTextToken,
|
|
||||||
'token_type' => 'Bearer',
|
|
||||||
'abilities' => $abilities,
|
|
||||||
'user' => Arr::only($user->toArray(), [
|
|
||||||
'id',
|
|
||||||
'email',
|
|
||||||
'name',
|
|
||||||
'role',
|
|
||||||
'tenant_id',
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$token = $request->user()?->currentAccessToken();
|
|
||||||
|
|
||||||
if ($token) {
|
|
||||||
$token->delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function me(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$user = $request->user();
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'user' => $user ? Arr::only($user->toArray(), [
|
|
||||||
'id',
|
|
||||||
'name',
|
|
||||||
'email',
|
|
||||||
'role',
|
|
||||||
'tenant_id',
|
|
||||||
]) : null,
|
|
||||||
'abilities' => $user?->currentAccessToken()?->abilities ?? [],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\Support;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Http\Requests\Support\SupportWatermarkSettingsRequest;
|
|
||||||
use App\Models\WatermarkSetting;
|
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
|
||||||
use App\Support\SupportApiAuthorizer;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
|
|
||||||
class SupportWatermarkSettingsController extends Controller
|
|
||||||
{
|
|
||||||
public function show(): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = SupportApiAuthorizer::authorizeAbilities(request(), ['support:settings'], 'settings')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$settings = WatermarkSetting::query()->first();
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => $settings,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(SupportWatermarkSettingsRequest $request): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = SupportApiAuthorizer::authorizeAbilities($request, ['support:settings'], 'settings')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$settings = WatermarkSetting::query()->firstOrNew([]);
|
|
||||||
$settings->fill($request->validated());
|
|
||||||
$settings->save();
|
|
||||||
|
|
||||||
$changed = $settings->getChanges();
|
|
||||||
|
|
||||||
if ($changed !== []) {
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'watermark_settings.updated',
|
|
||||||
$settings,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => $settings->refresh(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,7 @@ use App\Models\Event;
|
|||||||
use App\Services\Analytics\EventAnalyticsService;
|
use App\Services\Analytics\EventAnalyticsService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
class EventAnalyticsController extends Controller
|
class EventAnalyticsController extends Controller
|
||||||
{
|
{
|
||||||
@@ -22,13 +23,13 @@ class EventAnalyticsController extends Controller
|
|||||||
if (is_string($packageFeatures)) {
|
if (is_string($packageFeatures)) {
|
||||||
$packageFeatures = json_decode($packageFeatures, true) ?? [];
|
$packageFeatures = json_decode($packageFeatures, true) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$hasAccess = in_array('advanced_analytics', $packageFeatures, true);
|
$hasAccess = in_array('advanced_analytics', $packageFeatures, true);
|
||||||
|
|
||||||
if (! $hasAccess) {
|
if (!$hasAccess) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'This feature is only available in the Premium package.',
|
'message' => 'This feature is only available in the Premium package.',
|
||||||
'code' => 'feature_locked',
|
'code' => 'feature_locked'
|
||||||
], 403);
|
], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ use App\Models\Tenant;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\EventJoinTokenService;
|
use App\Services\EventJoinTokenService;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
use App\Support\TenantMemberPermissions;
|
|
||||||
use App\Support\WatermarkConfigResolver;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
@@ -85,8 +83,6 @@ class EventController extends Controller
|
|||||||
|
|
||||||
public function store(EventStoreRequest $request): JsonResponse
|
public function store(EventStoreRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
TenantMemberPermissions::ensureTenantPermission($request, 'events:manage');
|
|
||||||
|
|
||||||
$tenant = $request->attributes->get('tenant');
|
$tenant = $request->attributes->get('tenant');
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
@@ -103,9 +99,6 @@ class EventController extends Controller
|
|||||||
|
|
||||||
$requestedPackageId = $isSuperAdmin ? $request->integer('package_id') : null;
|
$requestedPackageId = $isSuperAdmin ? $request->integer('package_id') : null;
|
||||||
unset($validated['package_id']);
|
unset($validated['package_id']);
|
||||||
$requestedServiceSlug = $request->input('service_package_slug');
|
|
||||||
$requestedServiceSlug = is_string($requestedServiceSlug) && $requestedServiceSlug !== '' ? $requestedServiceSlug : null;
|
|
||||||
unset($validated['service_package_slug']);
|
|
||||||
|
|
||||||
$tenantPackage = $tenant->tenantPackages()
|
$tenantPackage = $tenant->tenantPackages()
|
||||||
->with('package')
|
->with('package')
|
||||||
@@ -123,18 +116,6 @@ class EventController extends Controller
|
|||||||
$package = $this->resolveOwnerPackage();
|
$package = $this->resolveOwnerPackage();
|
||||||
}
|
}
|
||||||
|
|
||||||
$billingTenantPackage = null;
|
|
||||||
if (! $package) {
|
|
||||||
$billingTenantPackage = $requestedServiceSlug
|
|
||||||
? $tenant->getActiveResellerPackageFor($requestedServiceSlug)
|
|
||||||
: $tenant->getActiveResellerPackage();
|
|
||||||
|
|
||||||
if ($billingTenantPackage && $billingTenantPackage->package) {
|
|
||||||
$package = $billingTenantPackage->package;
|
|
||||||
$requestedServiceSlug = $requestedServiceSlug ?: $package->included_package_slug;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $package && $tenantPackage) {
|
if (! $package && $tenantPackage) {
|
||||||
$package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id);
|
$package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id);
|
||||||
}
|
}
|
||||||
@@ -145,11 +126,6 @@ class EventController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$billingIsReseller = $package->isReseller();
|
|
||||||
$eventServicePackage = $billingIsReseller
|
|
||||||
? $this->resolveResellerEventPackageForSlug($requestedServiceSlug ?: $package->included_package_slug)
|
|
||||||
: $package;
|
|
||||||
|
|
||||||
$requiresWaiver = $package->isEndcustomer();
|
$requiresWaiver = $package->isEndcustomer();
|
||||||
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
|
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
|
||||||
$existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null;
|
$existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null;
|
||||||
@@ -161,13 +137,11 @@ class EventController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$resolvedName = $this->resolveEventNameString($validated['name']);
|
|
||||||
$eventData = array_merge($validated, [
|
$eventData = array_merge($validated, [
|
||||||
'tenant_id' => $tenantId,
|
'tenant_id' => $tenantId,
|
||||||
'status' => $validated['status'] ?? 'draft',
|
'status' => $validated['status'] ?? 'draft',
|
||||||
'slug' => $this->generateUniqueSlug($resolvedName, $tenantId),
|
'slug' => $this->generateUniqueSlug($validated['name'], $tenantId),
|
||||||
]);
|
]);
|
||||||
$eventData['name'] = $this->normalizeEventName($validated['name']);
|
|
||||||
|
|
||||||
if (isset($eventData['event_date'])) {
|
if (isset($eventData['event_date'])) {
|
||||||
$eventData['date'] = $eventData['event_date'];
|
$eventData['date'] = $eventData['event_date'];
|
||||||
@@ -187,8 +161,8 @@ class EventController extends Controller
|
|||||||
unset($eventData['features']);
|
unset($eventData['features']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$settings['branding_allowed'] = $eventServicePackage->branding_allowed !== false;
|
$settings['branding_allowed'] = $package->branding_allowed !== false;
|
||||||
$settings['watermark_allowed'] = $eventServicePackage->watermark_allowed !== false;
|
$settings['watermark_allowed'] = $package->watermark_allowed !== false;
|
||||||
|
|
||||||
$eventData['settings'] = $settings;
|
$eventData['settings'] = $settings;
|
||||||
|
|
||||||
@@ -216,23 +190,21 @@ class EventController extends Controller
|
|||||||
|
|
||||||
$eventData = Arr::only($eventData, $allowed);
|
$eventData = Arr::only($eventData, $allowed);
|
||||||
|
|
||||||
$event = DB::transaction(function () use ($tenant, $eventData, $eventServicePackage, $billingIsReseller, $isSuperAdmin) {
|
$event = DB::transaction(function () use ($tenant, $eventData, $package, $isSuperAdmin) {
|
||||||
$event = Event::create($eventData);
|
$event = Event::create($eventData);
|
||||||
|
|
||||||
EventPackage::create([
|
EventPackage::create([
|
||||||
'event_id' => $event->id,
|
'event_id' => $event->id,
|
||||||
'package_id' => $eventServicePackage->id,
|
'package_id' => $package->id,
|
||||||
'purchased_price' => $billingIsReseller ? 0 : $eventServicePackage->price,
|
'purchased_price' => $package->price,
|
||||||
'purchased_at' => now(),
|
'purchased_at' => now(),
|
||||||
'gallery_expires_at' => $eventServicePackage->gallery_days
|
'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null,
|
||||||
? now()->addDays($eventServicePackage->gallery_days)
|
|
||||||
: null,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($billingIsReseller && ! $isSuperAdmin) {
|
if ($package->isReseller() && ! $isSuperAdmin) {
|
||||||
$note = sprintf('Event #%d created (%s)', $event->id, $this->resolveEventNameString($event->name));
|
$note = sprintf('Event #%d created (%s)', $event->id, $event->name);
|
||||||
|
|
||||||
if (! $tenant->consumeEventAllowanceFor($eventServicePackage->slug, 1, 'event.create', $note)) {
|
if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) {
|
||||||
throw new HttpException(402, 'Insufficient package allowance.');
|
throw new HttpException(402, 'Insufficient package allowance.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,47 +227,6 @@ class EventController extends Controller
|
|||||||
], 201);
|
], 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveResellerDefaultEventPackage(): Package
|
|
||||||
{
|
|
||||||
return $this->resolveResellerEventPackageForSlug('standard');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveResellerEventPackageForSlug(?string $slug): Package
|
|
||||||
{
|
|
||||||
if (is_string($slug) && $slug !== '') {
|
|
||||||
$match = Package::query()
|
|
||||||
->where('type', 'endcustomer')
|
|
||||||
->where('slug', $slug)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($match) {
|
|
||||||
return $match;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$default = Package::query()
|
|
||||||
->where('type', 'endcustomer')
|
|
||||||
->where('slug', 'standard')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($default) {
|
|
||||||
return $default;
|
|
||||||
}
|
|
||||||
|
|
||||||
$fallback = Package::query()
|
|
||||||
->where('type', 'endcustomer')
|
|
||||||
->orderBy('price')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $fallback) {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'package_id' => __('Aktuell ist kein Endkunden-Paket verfügbar. Bitte kontaktiere den Support.'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveLatestPackagePurchase(Tenant $tenant, Package $package): ?PackagePurchase
|
private function resolveLatestPackagePurchase(Tenant $tenant, Package $package): ?PackagePurchase
|
||||||
{
|
{
|
||||||
return PackagePurchase::query()
|
return PackagePurchase::query()
|
||||||
@@ -389,30 +320,16 @@ class EventController extends Controller
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'events:manage');
|
|
||||||
|
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
$nameProvided = array_key_exists('name', $validated);
|
|
||||||
|
|
||||||
$validated = array_merge([
|
|
||||||
'name' => $event->name,
|
|
||||||
'event_type_id' => $event->event_type_id,
|
|
||||||
'event_date' => $event->date?->toDateString(),
|
|
||||||
'status' => $event->status,
|
|
||||||
], $validated);
|
|
||||||
|
|
||||||
if (isset($validated['event_date'])) {
|
if (isset($validated['event_date'])) {
|
||||||
$validated['date'] = $validated['event_date'];
|
$validated['date'] = $validated['event_date'];
|
||||||
unset($validated['event_date']);
|
unset($validated['event_date']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$currentName = $this->resolveEventNameString($event->name);
|
if ($validated['name'] !== $event->name) {
|
||||||
$nextName = $this->resolveEventNameString($validated['name']);
|
$validated['slug'] = $this->generateUniqueSlug($validated['name'], $tenantId, $event->id);
|
||||||
|
|
||||||
if ($nameProvided && $nextName !== $currentName) {
|
|
||||||
$validated['slug'] = $this->generateUniqueSlug($nextName, $tenantId, $event->id);
|
|
||||||
}
|
}
|
||||||
$validated['name'] = $this->normalizeEventName($validated['name']);
|
|
||||||
|
|
||||||
foreach (['password', 'password_confirmation', 'password_protected'] as $unused) {
|
foreach (['password', 'password_confirmation', 'password_protected'] as $unused) {
|
||||||
unset($validated[$unused]);
|
unset($validated[$unused]);
|
||||||
@@ -421,7 +338,6 @@ class EventController extends Controller
|
|||||||
$package = $event->eventPackage?->package;
|
$package = $event->eventPackage?->package;
|
||||||
$brandingAllowed = optional($package)->branding_allowed !== false;
|
$brandingAllowed = optional($package)->branding_allowed !== false;
|
||||||
$watermarkAllowed = optional($package)->watermark_allowed !== false;
|
$watermarkAllowed = optional($package)->watermark_allowed !== false;
|
||||||
$watermarkRemovalAllowed = WatermarkConfigResolver::determineRemovalAllowed($event);
|
|
||||||
|
|
||||||
if (isset($validated['settings']) && is_array($validated['settings'])) {
|
if (isset($validated['settings']) && is_array($validated['settings'])) {
|
||||||
$validated['settings'] = array_merge($event->settings ?? [], $validated['settings']);
|
$validated['settings'] = array_merge($event->settings ?? [], $validated['settings']);
|
||||||
@@ -431,37 +347,32 @@ class EventController extends Controller
|
|||||||
|
|
||||||
$validated['settings']['branding_allowed'] = $brandingAllowed;
|
$validated['settings']['branding_allowed'] = $brandingAllowed;
|
||||||
$validated['settings']['watermark_allowed'] = $watermarkAllowed;
|
$validated['settings']['watermark_allowed'] = $watermarkAllowed;
|
||||||
$validated['settings']['watermark_removal_allowed'] = $watermarkRemovalAllowed;
|
|
||||||
|
|
||||||
$settings = $validated['settings'];
|
$settings = $validated['settings'];
|
||||||
$branding = Arr::get($settings, 'branding', []);
|
|
||||||
$watermark = Arr::get($settings, 'watermark', []);
|
$watermark = Arr::get($settings, 'watermark', []);
|
||||||
$existingWatermark = is_array($watermark) ? $watermark : [];
|
$existingWatermark = is_array($watermark) ? $watermark : [];
|
||||||
|
|
||||||
if (is_array($branding)) {
|
|
||||||
$settings['branding'] = $this->normalizeBrandingSettings($branding, $event, $brandingAllowed);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_array($watermark)) {
|
if (is_array($watermark)) {
|
||||||
$mode = $watermark['mode'] ?? 'base';
|
$mode = $watermark['mode'] ?? 'base';
|
||||||
|
$policy = $watermarkAllowed ? 'basic' : 'none';
|
||||||
|
|
||||||
if (! $watermarkAllowed) {
|
if (! $watermarkAllowed) {
|
||||||
$mode = 'base';
|
$mode = 'off';
|
||||||
} elseif (! $brandingAllowed) {
|
} elseif (! $brandingAllowed) {
|
||||||
$mode = 'base';
|
$mode = 'base';
|
||||||
} elseif ($mode === 'off' && ! $watermarkRemovalAllowed) {
|
} elseif ($mode === 'off' && $policy === 'basic') {
|
||||||
$mode = 'base';
|
$mode = 'base';
|
||||||
}
|
}
|
||||||
|
|
||||||
$assetPath = $watermark['asset'] ?? null;
|
$assetPath = $watermark['asset'] ?? null;
|
||||||
$assetDataUrl = $watermark['asset_data_url'] ?? null;
|
$assetDataUrl = $watermark['asset_data_url'] ?? null;
|
||||||
|
|
||||||
if (! $watermarkAllowed || $mode === 'off') {
|
if (! $watermarkAllowed) {
|
||||||
$assetPath = null;
|
$assetPath = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($assetDataUrl && $mode === 'custom' && $brandingAllowed) {
|
if ($assetDataUrl && $mode === 'custom' && $brandingAllowed) {
|
||||||
if (! preg_match('/^data:image\\/(png|webp|jpe?g|svg\\+xml);base64,(.+)$/i', $assetDataUrl, $matches)) {
|
if (! preg_match('/^data:image\\/(png|webp|jpe?g);base64,(.+)$/i', $assetDataUrl, $matches)) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'settings.watermark.asset_data_url' => __('Ungültiges Wasserzeichen-Bild.'),
|
'settings.watermark.asset_data_url' => __('Ungültiges Wasserzeichen-Bild.'),
|
||||||
]);
|
]);
|
||||||
@@ -481,12 +392,7 @@ class EventController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$mime = strtolower($matches[1]);
|
$extension = str_starts_with(strtolower($matches[1]), 'jp') ? 'jpg' : strtolower($matches[1]);
|
||||||
$extension = match (true) {
|
|
||||||
str_starts_with($mime, 'jp') => 'jpg',
|
|
||||||
str_starts_with($mime, 'svg') => 'svg',
|
|
||||||
default => $mime,
|
|
||||||
};
|
|
||||||
$path = sprintf('branding/watermarks/event-%s.%s', $event->id, $extension);
|
$path = sprintf('branding/watermarks/event-%s.%s', $event->id, $extension);
|
||||||
Storage::disk('public')->put($path, $decoded);
|
Storage::disk('public')->put($path, $decoded);
|
||||||
$assetPath = $path;
|
$assetPath = $path;
|
||||||
@@ -536,68 +442,6 @@ class EventController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $branding
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function normalizeBrandingSettings(array $branding, Event $event, bool $brandingAllowed): array
|
|
||||||
{
|
|
||||||
$logoDataUrl = $branding['logo_data_url'] ?? null;
|
|
||||||
|
|
||||||
if (! $brandingAllowed) {
|
|
||||||
unset($branding['logo_data_url']);
|
|
||||||
|
|
||||||
return $branding;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! is_string($logoDataUrl) || trim($logoDataUrl) === '') {
|
|
||||||
unset($branding['logo_data_url']);
|
|
||||||
|
|
||||||
return $branding;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! preg_match('/^data:image\\/(png|webp|jpe?g);base64,(.+)$/i', $logoDataUrl, $matches)) {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'settings.branding.logo_data_url' => __('Ungültiges Branding-Logo.'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$decoded = base64_decode($matches[2], true);
|
|
||||||
|
|
||||||
if ($decoded === false) {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'settings.branding.logo_data_url' => __('Branding-Logo konnte nicht gelesen werden.'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strlen($decoded) > 1024 * 1024) { // 1 MB
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'settings.branding.logo_data_url' => __('Branding-Logo ist zu groß (max. 1 MB).'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$extension = str_starts_with(strtolower($matches[1]), 'jp') ? 'jpg' : strtolower($matches[1]);
|
|
||||||
$path = sprintf('branding/logos/event-%s.%s', $event->id, $extension);
|
|
||||||
Storage::disk('public')->put($path, $decoded);
|
|
||||||
|
|
||||||
$branding['logo_url'] = $path;
|
|
||||||
$branding['logo_mode'] = 'upload';
|
|
||||||
$branding['logo_value'] = $path;
|
|
||||||
|
|
||||||
$logo = $branding['logo'] ?? [];
|
|
||||||
if (! is_array($logo)) {
|
|
||||||
$logo = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$logo['mode'] = 'upload';
|
|
||||||
$logo['value'] = $path;
|
|
||||||
$branding['logo'] = $logo;
|
|
||||||
|
|
||||||
unset($branding['logo_data_url']);
|
|
||||||
|
|
||||||
return $branding;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(Request $request, Event $event): JsonResponse
|
public function destroy(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
@@ -612,8 +456,6 @@ class EventController extends Controller
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'events:manage');
|
|
||||||
|
|
||||||
$event->delete();
|
$event->delete();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -941,45 +783,6 @@ class EventController extends Controller
|
|||||||
return $slug;
|
return $slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed>|string|null $name
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function normalizeEventName(mixed $name): array
|
|
||||||
{
|
|
||||||
if (is_array($name)) {
|
|
||||||
return $name;
|
|
||||||
}
|
|
||||||
|
|
||||||
$value = is_string($name) ? trim($name) : '';
|
|
||||||
|
|
||||||
return ['de' => $value];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed>|string|null $name
|
|
||||||
*/
|
|
||||||
private function resolveEventNameString(mixed $name): string
|
|
||||||
{
|
|
||||||
if (is_array($name)) {
|
|
||||||
$candidates = [
|
|
||||||
$name['de'] ?? null,
|
|
||||||
$name['en'] ?? null,
|
|
||||||
reset($name) ?: null,
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($candidates as $candidate) {
|
|
||||||
if (is_string($candidate) && $candidate !== '') {
|
|
||||||
return $candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return is_string($name) ? $name : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function search(Request $request): AnonymousResourceCollection
|
public function search(Request $request): AnonymousResourceCollection
|
||||||
{
|
{
|
||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use App\Models\Event;
|
|||||||
use App\Models\GuestNotification;
|
use App\Models\GuestNotification;
|
||||||
use App\Models\GuestPolicySetting;
|
use App\Models\GuestPolicySetting;
|
||||||
use App\Services\GuestNotificationService;
|
use App\Services\GuestNotificationService;
|
||||||
use App\Support\TenantMemberPermissions;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@@ -24,7 +23,6 @@ class EventGuestNotificationController extends Controller
|
|||||||
public function index(Request $request, Event $event): JsonResponse
|
public function index(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$this->assertEventTenant($request, $event);
|
$this->assertEventTenant($request, $event);
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'guest-notifications:manage');
|
|
||||||
|
|
||||||
$limit = max(1, min(100, (int) $request->integer('limit', 25)));
|
$limit = max(1, min(100, (int) $request->integer('limit', 25)));
|
||||||
|
|
||||||
@@ -40,7 +38,6 @@ class EventGuestNotificationController extends Controller
|
|||||||
public function store(BroadcastGuestNotificationRequest $request, Event $event): JsonResponse
|
public function store(BroadcastGuestNotificationRequest $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$this->assertEventTenant($request, $event);
|
$this->assertEventTenant($request, $event);
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'guest-notifications:manage');
|
|
||||||
|
|
||||||
$data = $request->validated();
|
$data = $request->validated();
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use App\Http\Resources\Tenant\EventJoinTokenResource;
|
|||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\EventJoinToken;
|
use App\Models\EventJoinToken;
|
||||||
use App\Services\EventJoinTokenService;
|
use App\Services\EventJoinTokenService;
|
||||||
use App\Support\TenantMemberPermissions;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
@@ -20,7 +19,7 @@ class EventJoinTokenController extends Controller
|
|||||||
|
|
||||||
public function index(Request $request, Event $event): AnonymousResourceCollection
|
public function index(Request $request, Event $event): AnonymousResourceCollection
|
||||||
{
|
{
|
||||||
$this->authorizeEvent($request, $event, 'join-tokens:manage');
|
$this->authorizeEvent($request, $event);
|
||||||
|
|
||||||
$tokens = $event->joinTokens()
|
$tokens = $event->joinTokens()
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
@@ -31,7 +30,7 @@ class EventJoinTokenController extends Controller
|
|||||||
|
|
||||||
public function store(Request $request, Event $event): JsonResponse
|
public function store(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$this->authorizeEvent($request, $event, 'join-tokens:manage');
|
$this->authorizeEvent($request, $event);
|
||||||
|
|
||||||
$validated = $this->validatePayload($request);
|
$validated = $this->validatePayload($request);
|
||||||
|
|
||||||
@@ -46,7 +45,7 @@ class EventJoinTokenController extends Controller
|
|||||||
|
|
||||||
public function update(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
|
public function update(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
|
||||||
{
|
{
|
||||||
$this->authorizeEvent($request, $event, 'join-tokens:manage');
|
$this->authorizeEvent($request, $event);
|
||||||
|
|
||||||
if ($joinToken->event_id !== $event->id) {
|
if ($joinToken->event_id !== $event->id) {
|
||||||
abort(404);
|
abort(404);
|
||||||
@@ -90,7 +89,7 @@ class EventJoinTokenController extends Controller
|
|||||||
|
|
||||||
public function destroy(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
|
public function destroy(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
|
||||||
{
|
{
|
||||||
$this->authorizeEvent($request, $event, 'join-tokens:manage');
|
$this->authorizeEvent($request, $event);
|
||||||
|
|
||||||
if ($joinToken->event_id !== $event->id) {
|
if ($joinToken->event_id !== $event->id) {
|
||||||
abort(404);
|
abort(404);
|
||||||
@@ -102,17 +101,13 @@ class EventJoinTokenController extends Controller
|
|||||||
return new EventJoinTokenResource($token);
|
return new EventJoinTokenResource($token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function authorizeEvent(Request $request, Event $event, ?string $permission = null): void
|
private function authorizeEvent(Request $request, Event $event): void
|
||||||
{
|
{
|
||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
|
|
||||||
if ($event->tenant_id !== $tenantId) {
|
if ($event->tenant_id !== $tenantId) {
|
||||||
abort(404, 'Event not found');
|
abort(404, 'Event not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($permission) {
|
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, $permission);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function validatePayload(Request $request, bool $partial = false): array
|
private function validatePayload(Request $request, bool $partial = false): array
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\EventJoinToken;
|
use App\Models\EventJoinToken;
|
||||||
use App\Support\JoinTokenLayoutRegistry;
|
use App\Support\JoinTokenLayoutRegistry;
|
||||||
use App\Support\TenantMemberPermissions;
|
|
||||||
use Dompdf\Dompdf;
|
use Dompdf\Dompdf;
|
||||||
use Dompdf\Options;
|
use Dompdf\Options;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -22,21 +21,13 @@ class EventJoinTokenLayoutController extends Controller
|
|||||||
*/
|
*/
|
||||||
private const BACKGROUND_PRESETS = [
|
private const BACKGROUND_PRESETS = [
|
||||||
'bg-blue-floral' => 'storage/layouts/backgrounds-portrait/bg-blue-floral.png',
|
'bg-blue-floral' => 'storage/layouts/backgrounds-portrait/bg-blue-floral.png',
|
||||||
'bg-artdeco' => 'storage/layouts/backgrounds-portrait/bg-artdeco.png',
|
|
||||||
'bg-eukalyptus-floral' => 'storage/layouts/backgrounds-portrait/bg-eukalyptus-floral.png',
|
|
||||||
'bg-eukalyptus-rahmen' => 'storage/layouts/backgrounds-portrait/bg-eukalyptus-rahmen.png',
|
|
||||||
'bg-eukalyptus' => 'storage/layouts/backgrounds-portrait/bg-eukalyptus.png',
|
|
||||||
'bg-goldframe' => 'storage/layouts/backgrounds-portrait/bg-goldframe.png',
|
'bg-goldframe' => 'storage/layouts/backgrounds-portrait/bg-goldframe.png',
|
||||||
'bg-jugendstil' => 'storage/layouts/backgrounds-portrait/bg-jugendstil.png',
|
|
||||||
'bg-kornblumen' => 'storage/layouts/backgrounds-portrait/bg-kornblumen.png',
|
|
||||||
'bg-kornblumen2' => 'storage/layouts/backgrounds-portrait/bg-kornblumen2.png',
|
|
||||||
'gr-green-floral' => 'storage/layouts/backgrounds-portrait/gr-green-floral.png',
|
'gr-green-floral' => 'storage/layouts/backgrounds-portrait/gr-green-floral.png',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function index(Request $request, Event $event, EventJoinToken $joinToken)
|
public function index(Request $request, Event $event, EventJoinToken $joinToken)
|
||||||
{
|
{
|
||||||
$this->ensureBelongsToEvent($event, $joinToken);
|
$this->ensureBelongsToEvent($event, $joinToken);
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'join-tokens:manage');
|
|
||||||
|
|
||||||
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $joinToken) {
|
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $joinToken) {
|
||||||
return route('api.v1.tenant.events.join-tokens.layouts.download', [
|
return route('api.v1.tenant.events.join-tokens.layouts.download', [
|
||||||
@@ -55,7 +46,6 @@ class EventJoinTokenLayoutController extends Controller
|
|||||||
public function download(Request $request, Event $event, EventJoinToken $joinToken, string $layout, string $format)
|
public function download(Request $request, Event $event, EventJoinToken $joinToken, string $layout, string $format)
|
||||||
{
|
{
|
||||||
$this->ensureBelongsToEvent($event, $joinToken);
|
$this->ensureBelongsToEvent($event, $joinToken);
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'join-tokens:manage');
|
|
||||||
|
|
||||||
$layoutConfig = JoinTokenLayoutRegistry::find($layout);
|
$layoutConfig = JoinTokenLayoutRegistry::find($layout);
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ use App\Models\Event;
|
|||||||
use App\Models\EventMember;
|
use App\Models\EventMember;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Support\TenantMemberPermissions;
|
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -23,7 +22,6 @@ class EventMemberController extends Controller
|
|||||||
public function index(Request $request, Event $event): JsonResponse
|
public function index(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$this->assertEventTenant($request, $event);
|
$this->assertEventTenant($request, $event);
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage');
|
|
||||||
|
|
||||||
/** @var LengthAwarePaginator $members */
|
/** @var LengthAwarePaginator $members */
|
||||||
$members = $event->members()
|
$members = $event->members()
|
||||||
@@ -36,7 +34,6 @@ class EventMemberController extends Controller
|
|||||||
public function store(EventMemberInviteRequest $request, Event $event): JsonResponse
|
public function store(EventMemberInviteRequest $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$this->assertEventTenant($request, $event);
|
$this->assertEventTenant($request, $event);
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage');
|
|
||||||
|
|
||||||
$data = $request->validated();
|
$data = $request->validated();
|
||||||
$tenant = $this->resolveTenantFromRequest($request);
|
$tenant = $this->resolveTenantFromRequest($request);
|
||||||
@@ -95,7 +92,6 @@ class EventMemberController extends Controller
|
|||||||
public function destroy(Request $request, Event $event, EventMember $member): JsonResponse
|
public function destroy(Request $request, Event $event, EventMember $member): JsonResponse
|
||||||
{
|
{
|
||||||
$this->assertEventTenant($request, $event);
|
$this->assertEventTenant($request, $event);
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage');
|
|
||||||
|
|
||||||
if ((int) $member->event_id !== (int) $event->id) {
|
if ((int) $member->event_id !== (int) $event->id) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
|
|||||||
@@ -112,3 +112,4 @@ class FontController extends Controller
|
|||||||
return $fonts;
|
return $fonts;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ namespace App\Http\Controllers\Api\Tenant;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Support\TenantMemberPermissions;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||||
@@ -14,7 +13,6 @@ class LiveShowLinkController extends Controller
|
|||||||
public function show(Request $request, Event $event): JsonResponse
|
public function show(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$this->authorizeEvent($request, $event);
|
$this->authorizeEvent($request, $event);
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'live-show:manage');
|
|
||||||
|
|
||||||
$token = $event->ensureLiveShowToken();
|
$token = $event->ensureLiveShowToken();
|
||||||
|
|
||||||
@@ -26,7 +24,6 @@ class LiveShowLinkController extends Controller
|
|||||||
public function rotate(Request $request, Event $event): JsonResponse
|
public function rotate(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$this->authorizeEvent($request, $event);
|
$this->authorizeEvent($request, $event);
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'live-show:manage');
|
|
||||||
|
|
||||||
$token = $event->rotateLiveShowToken();
|
$token = $event->rotateLiveShowToken();
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ use App\Http\Resources\Tenant\PhotoResource;
|
|||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\Photo;
|
use App\Models\Photo;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
use App\Support\TenantMemberPermissions;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
@@ -24,7 +23,6 @@ class LiveShowPhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
|
||||||
|
|
||||||
$liveStatus = $request->string('live_status', 'pending')->toString();
|
$liveStatus = $request->string('live_status', 'pending')->toString();
|
||||||
$perPage = (int) $request->input('per_page', 20);
|
$perPage = (int) $request->input('per_page', 20);
|
||||||
@@ -53,7 +51,6 @@ class LiveShowPhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
if ($photo->event_id !== $event->id) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
@@ -97,7 +94,6 @@ class LiveShowPhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
if ($photo->event_id !== $event->id) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
@@ -150,7 +146,6 @@ class LiveShowPhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
if ($photo->event_id !== $event->id) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
@@ -178,7 +173,6 @@ class LiveShowPhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
if ($photo->event_id !== $event->id) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
|
|||||||
@@ -14,14 +14,11 @@ use App\Services\Packages\PackageUsageTracker;
|
|||||||
use App\Services\Storage\EventStorageManager;
|
use App\Services\Storage\EventStorageManager;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
use App\Support\ImageHelper;
|
use App\Support\ImageHelper;
|
||||||
use App\Support\TenantMemberPermissions;
|
|
||||||
use App\Support\UploadStream;
|
use App\Support\UploadStream;
|
||||||
use App\Support\WatermarkConfigResolver;
|
use App\Support\WatermarkConfigResolver;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
use Illuminate\Http\UploadedFile;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
@@ -116,7 +113,6 @@ class PhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
if ($photo->event_id !== $event->id) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
@@ -134,11 +130,6 @@ class PhotoController extends Controller
|
|||||||
|
|
||||||
$photo->status = $validated['visible'] ? 'approved' : 'hidden';
|
$photo->status = $validated['visible'] ? 'approved' : 'hidden';
|
||||||
$photo->save();
|
$photo->save();
|
||||||
|
|
||||||
$autoRemoveLiveOnHide = (bool) Arr::get($event->settings ?? [], 'control_room.auto_remove_live_on_hide', true);
|
|
||||||
if ($autoRemoveLiveOnHide && ! $validated['visible']) {
|
|
||||||
$photo->rejectForLiveShow($request->user(), 'hidden');
|
|
||||||
}
|
|
||||||
$photo->load('event')->loadCount('likes');
|
$photo->load('event')->loadCount('likes');
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -323,7 +314,7 @@ class PhotoController extends Controller
|
|||||||
$disk = $this->eventStorageManager->getHotDiskForEvent($event);
|
$disk = $this->eventStorageManager->getHotDiskForEvent($event);
|
||||||
|
|
||||||
// Generate unique filename
|
// Generate unique filename
|
||||||
$extension = $this->resolvePhotoExtension($file);
|
$extension = $file->getClientOriginalExtension();
|
||||||
$filename = Str::uuid().'.'.$extension;
|
$filename = Str::uuid().'.'.$extension;
|
||||||
$path = "events/{$eventSlug}/photos/{$filename}";
|
$path = "events/{$eventSlug}/photos/{$filename}";
|
||||||
|
|
||||||
@@ -533,17 +524,19 @@ class PhotoController extends Controller
|
|||||||
'alt_text' => ['sometimes', 'string', 'max:255'],
|
'alt_text' => ['sometimes', 'string', 'max:255'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (isset($validated['status'])) {
|
// Only tenant admins can moderate
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant-admin')) {
|
||||||
|
return ApiError::response(
|
||||||
|
'insufficient_scope',
|
||||||
|
'Insufficient Scopes',
|
||||||
|
'You are not allowed to moderate photos for this event.',
|
||||||
|
Response::HTTP_FORBIDDEN,
|
||||||
|
['required_scope' => 'tenant-admin']
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$photo->update($validated);
|
$photo->update($validated);
|
||||||
|
|
||||||
$autoRemoveLiveOnHide = (bool) Arr::get($event->settings ?? [], 'control_room.auto_remove_live_on_hide', true);
|
|
||||||
if ($autoRemoveLiveOnHide && ($validated['status'] ?? null) === 'rejected') {
|
|
||||||
$photo->rejectForLiveShow($request->user());
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($validated['status'] ?? null === 'approved') {
|
if ($validated['status'] ?? null === 'approved') {
|
||||||
$photo->load('event')->loadCount('likes');
|
$photo->load('event')->loadCount('likes');
|
||||||
// Trigger event for new photo notification
|
// Trigger event for new photo notification
|
||||||
@@ -565,7 +558,6 @@ class PhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
if ($photo->event_id !== $event->id) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
@@ -642,7 +634,6 @@ class PhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
if ($photo->event_id !== $event->id) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
@@ -666,7 +657,6 @@ class PhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
if ($photo->event_id !== $event->id) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
@@ -690,7 +680,6 @@ class PhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
|
||||||
|
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'photo_ids' => 'required|array',
|
'photo_ids' => 'required|array',
|
||||||
@@ -736,7 +725,6 @@ class PhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
|
||||||
|
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'photo_ids' => 'required|array',
|
'photo_ids' => 'required|array',
|
||||||
@@ -782,7 +770,6 @@ class PhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
|
||||||
|
|
||||||
$photos = Photo::where('event_id', $event->id)
|
$photos = Photo::where('event_id', $event->id)
|
||||||
->where('status', 'pending')
|
->where('status', 'pending')
|
||||||
@@ -1047,23 +1034,4 @@ class PhotoController extends Controller
|
|||||||
|
|
||||||
return array_values(array_unique(array_filter($candidates)));
|
return array_values(array_unique(array_filter($candidates)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolvePhotoExtension(UploadedFile $file): string
|
|
||||||
{
|
|
||||||
$extension = strtolower((string) $file->extension());
|
|
||||||
|
|
||||||
if (! in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) {
|
|
||||||
$extension = strtolower((string) $file->getClientOriginalExtension());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) {
|
|
||||||
$extension = match ($file->getMimeType()) {
|
|
||||||
'image/png' => 'png',
|
|
||||||
'image/webp' => 'webp',
|
|
||||||
default => 'jpg',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return $extension === 'jpeg' ? 'jpg' : $extension;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,12 @@
|
|||||||
namespace App\Http\Controllers\Api\Tenant;
|
namespace App\Http\Controllers\Api\Tenant;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Photobooth\PhotoboothSendUploaderDownloadRequest;
|
|
||||||
use App\Http\Resources\Tenant\PhotoboothStatusResource;
|
use App\Http\Resources\Tenant\PhotoboothStatusResource;
|
||||||
use App\Mail\PhotoboothUploaderDownload;
|
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\PhotoboothSetting;
|
use App\Models\PhotoboothSetting;
|
||||||
use App\Services\Photobooth\PhotoboothProvisioner;
|
use App\Services\Photobooth\PhotoboothProvisioner;
|
||||||
use App\Support\LocaleConfig;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Mail;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
|
|
||||||
class PhotoboothController extends Controller
|
class PhotoboothController extends Controller
|
||||||
{
|
{
|
||||||
@@ -74,39 +69,6 @@ class PhotoboothController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function sendUploaderDownloadEmail(PhotoboothSendUploaderDownloadRequest $request, Event $event): JsonResponse
|
|
||||||
{
|
|
||||||
$this->assertEventBelongsToTenant($request, $event);
|
|
||||||
|
|
||||||
$user = $request->user();
|
|
||||||
|
|
||||||
if (! $user || ! $user->email) {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'email' => __('No email address is configured for this account.'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$locale = LocaleConfig::canonicalize($user->preferred_locale ?: app()->getLocale());
|
|
||||||
$eventName = $this->resolveEventName($event, $locale);
|
|
||||||
$recipientName = $user->fullName ?? $user->name ?? $user->email;
|
|
||||||
|
|
||||||
$mail = (new PhotoboothUploaderDownload(
|
|
||||||
recipientName: $recipientName,
|
|
||||||
eventName: $eventName,
|
|
||||||
links: [
|
|
||||||
'windows' => url('/downloads/PhotoboothUploader-win-x64.exe'),
|
|
||||||
'macos' => url('/downloads/PhotoboothUploader-macos-x64'),
|
|
||||||
'linux' => url('/downloads/PhotoboothUploader-linux-x64'),
|
|
||||||
],
|
|
||||||
))->locale($locale);
|
|
||||||
|
|
||||||
Mail::to($user->email)->queue($mail);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'message' => __('Download links sent via email.'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function resource(Event $event): PhotoboothStatusResource
|
protected function resource(Event $event): PhotoboothStatusResource
|
||||||
{
|
{
|
||||||
return PhotoboothStatusResource::make([
|
return PhotoboothStatusResource::make([
|
||||||
@@ -130,30 +92,4 @@ class PhotoboothController extends Controller
|
|||||||
|
|
||||||
return in_array($mode, ['sparkbooth', 'ftp'], true) ? $mode : 'ftp';
|
return in_array($mode, ['sparkbooth', 'ftp'], true) ? $mode : 'ftp';
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function resolveEventName(Event $event, ?string $locale = null): string
|
|
||||||
{
|
|
||||||
$name = $event->name;
|
|
||||||
|
|
||||||
if (is_string($name) && trim($name) !== '') {
|
|
||||||
return $name;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_array($name)) {
|
|
||||||
$locale = $locale ?: app()->getLocale();
|
|
||||||
$localized = $name[$locale] ?? null;
|
|
||||||
|
|
||||||
if (is_string($localized) && trim($localized) !== '') {
|
|
||||||
return $localized;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($name as $value) {
|
|
||||||
if (is_string($value) && trim($value) !== '') {
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $event->slug ?: __('emails.photobooth_uploader.event_fallback');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,8 +113,8 @@ class SettingsController extends Controller
|
|||||||
$defaultSettings = [
|
$defaultSettings = [
|
||||||
'branding' => [
|
'branding' => [
|
||||||
'logo_url' => null,
|
'logo_url' => null,
|
||||||
'primary_color' => '#FF5A5F',
|
'primary_color' => '#3B82F6',
|
||||||
'secondary_color' => '#FFF8F5',
|
'secondary_color' => '#1F2937',
|
||||||
'font_family' => 'Inter, sans-serif',
|
'font_family' => 'Inter, sans-serif',
|
||||||
],
|
],
|
||||||
'features' => [
|
'features' => [
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ class TaskCollectionController extends Controller
|
|||||||
),
|
),
|
||||||
'created_task_ids' => $result['created_task_ids'],
|
'created_task_ids' => $result['created_task_ids'],
|
||||||
'attached_task_ids' => $result['attached_task_ids'],
|
'attached_task_ids' => $result['attached_task_ids'],
|
||||||
'skipped_task_ids' => $result['skipped_task_ids'],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ use App\Models\Event;
|
|||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use App\Models\TaskCollection;
|
use App\Models\TaskCollection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Packages\PackageLimitEvaluator;
|
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
use App\Support\TenantMemberPermissions;
|
|
||||||
use App\Support\TenantRequestResolver;
|
use App\Support\TenantRequestResolver;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -21,8 +19,6 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
|
|
||||||
class TaskController extends Controller
|
class TaskController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(private readonly PackageLimitEvaluator $packageLimitEvaluator) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display a listing of the tenant's tasks.
|
* Display a listing of the tenant's tasks.
|
||||||
*/
|
*/
|
||||||
@@ -70,8 +66,6 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function store(TaskStoreRequest $request): JsonResponse
|
public function store(TaskStoreRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage');
|
|
||||||
|
|
||||||
$tenant = $this->currentTenant($request);
|
$tenant = $this->currentTenant($request);
|
||||||
$collectionId = $request->input('collection_id');
|
$collectionId = $request->input('collection_id');
|
||||||
$collection = $collectionId ? $this->resolveAccessibleCollection($request, $collectionId) : null;
|
$collection = $collectionId ? $this->resolveAccessibleCollection($request, $collectionId) : null;
|
||||||
@@ -113,8 +107,6 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function update(TaskUpdateRequest $request, Task $task): JsonResponse
|
public function update(TaskUpdateRequest $request, Task $task): JsonResponse
|
||||||
{
|
{
|
||||||
TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage');
|
|
||||||
|
|
||||||
$tenant = $this->currentTenant($request);
|
$tenant = $this->currentTenant($request);
|
||||||
|
|
||||||
if ($task->tenant_id !== $tenant->id) {
|
if ($task->tenant_id !== $tenant->id) {
|
||||||
@@ -146,8 +138,6 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function destroy(Request $request, Task $task): JsonResponse
|
public function destroy(Request $request, Task $task): JsonResponse
|
||||||
{
|
{
|
||||||
TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage');
|
|
||||||
|
|
||||||
if ($task->tenant_id !== $this->currentTenant($request)->id) {
|
if ($task->tenant_id !== $this->currentTenant($request)->id) {
|
||||||
abort(404, 'Task nicht gefunden.');
|
abort(404, 'Task nicht gefunden.');
|
||||||
}
|
}
|
||||||
@@ -164,10 +154,7 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function assignToEvent(Request $request, Task $task, Event $event): JsonResponse
|
public function assignToEvent(Request $request, Task $task, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
|
$tenantId = $this->currentTenant($request)->id;
|
||||||
|
|
||||||
$tenant = $this->currentTenant($request);
|
|
||||||
$tenantId = $tenant->id;
|
|
||||||
|
|
||||||
if (($task->tenant_id && $task->tenant_id !== $tenantId) || $event->tenant_id !== $tenantId) {
|
if (($task->tenant_id && $task->tenant_id !== $tenantId) || $event->tenant_id !== $tenantId) {
|
||||||
abort(404);
|
abort(404);
|
||||||
@@ -177,11 +164,6 @@ class TaskController extends Controller
|
|||||||
return response()->json(['message' => 'Task ist bereits diesem Event zugewiesen.'], 409);
|
return response()->json(['message' => 'Task ist bereits diesem Event zugewiesen.'], 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
$limitStatus = $this->resolveTaskLimitStatus($event, $tenant);
|
|
||||||
if ($limitStatus['remaining'] !== null && $limitStatus['remaining'] <= 0) {
|
|
||||||
return $this->taskLimitExceededResponse($event, $limitStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
$task->assignedEvents()->attach($event->id);
|
$task->assignedEvents()->attach($event->id);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -194,10 +176,7 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function bulkAssignToEvent(Request $request, Event $event): JsonResponse
|
public function bulkAssignToEvent(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
|
$tenantId = $this->currentTenant($request)->id;
|
||||||
|
|
||||||
$tenant = $this->currentTenant($request);
|
|
||||||
$tenantId = $tenant->id;
|
|
||||||
|
|
||||||
if ($event->tenant_id !== $tenantId) {
|
if ($event->tenant_id !== $tenantId) {
|
||||||
abort(404);
|
abort(404);
|
||||||
@@ -213,27 +192,12 @@ class TaskController extends Controller
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$taskIds = array_values(array_unique(array_map('intval', $taskIds)));
|
|
||||||
$tasks = Task::whereIn('id', $taskIds)
|
$tasks = Task::whereIn('id', $taskIds)
|
||||||
->where(function ($query) use ($tenantId) {
|
->where(function ($query) use ($tenantId) {
|
||||||
$query->whereNull('tenant_id')->orWhere('tenant_id', $tenantId);
|
$query->whereNull('tenant_id')->orWhere('tenant_id', $tenantId);
|
||||||
})
|
})
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$assignedIds = $event->tasks()
|
|
||||||
->whereIn('tasks.id', $taskIds)
|
|
||||||
->pluck('tasks.id')
|
|
||||||
->all();
|
|
||||||
$pendingIds = array_values(array_diff($taskIds, $assignedIds));
|
|
||||||
$limitStatus = $this->resolveTaskLimitStatus($event, $tenant);
|
|
||||||
if (
|
|
||||||
$limitStatus['remaining'] !== null
|
|
||||||
&& $pendingIds !== []
|
|
||||||
&& $limitStatus['remaining'] < count($pendingIds)
|
|
||||||
) {
|
|
||||||
return $this->taskLimitExceededResponse($event, $limitStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
$attached = 0;
|
$attached = 0;
|
||||||
foreach ($tasks as $task) {
|
foreach ($tasks as $task) {
|
||||||
if (! $task->assignedEvents()->where('event_id', $event->id)->exists()) {
|
if (! $task->assignedEvents()->where('event_id', $event->id)->exists()) {
|
||||||
@@ -266,8 +230,6 @@ class TaskController extends Controller
|
|||||||
|
|
||||||
public function bulkDetachFromEvent(Request $request, Event $event): JsonResponse
|
public function bulkDetachFromEvent(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
|
|
||||||
|
|
||||||
$tenantId = $this->currentTenant($request)->id;
|
$tenantId = $this->currentTenant($request)->id;
|
||||||
|
|
||||||
if ($event->tenant_id !== $tenantId) {
|
if ($event->tenant_id !== $tenantId) {
|
||||||
@@ -294,8 +256,6 @@ class TaskController extends Controller
|
|||||||
|
|
||||||
public function reorderForEvent(Request $request, Event $event): JsonResponse
|
public function reorderForEvent(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
|
|
||||||
|
|
||||||
$tenantId = $this->currentTenant($request)->id;
|
$tenantId = $this->currentTenant($request)->id;
|
||||||
|
|
||||||
if ($event->tenant_id !== $tenantId) {
|
if ($event->tenant_id !== $tenantId) {
|
||||||
@@ -355,52 +315,6 @@ class TaskController extends Controller
|
|||||||
return TenantRequestResolver::resolve($request);
|
return TenantRequestResolver::resolve($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{limit: ?int, used: int, remaining: ?int, package_id: ?int}
|
|
||||||
*/
|
|
||||||
protected function resolveTaskLimitStatus(Event $event, Tenant $tenant): array
|
|
||||||
{
|
|
||||||
$event->loadMissing(['eventPackage.package', 'eventPackages.package']);
|
|
||||||
|
|
||||||
$eventPackage = $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload(
|
|
||||||
$tenant,
|
|
||||||
$event->id,
|
|
||||||
$event
|
|
||||||
);
|
|
||||||
|
|
||||||
$limit = $eventPackage?->effectiveLimits()['max_tasks'] ?? null;
|
|
||||||
$used = $event->tasks()->count();
|
|
||||||
$remaining = $limit === null ? null : max(0, (int) $limit - $used);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'limit' => $limit === null ? null : (int) $limit,
|
|
||||||
'used' => $used,
|
|
||||||
'remaining' => $remaining,
|
|
||||||
'package_id' => $eventPackage?->package_id,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{limit: ?int, used: int, remaining: ?int, package_id: ?int} $limitStatus
|
|
||||||
*/
|
|
||||||
protected function taskLimitExceededResponse(Event $event, array $limitStatus): JsonResponse
|
|
||||||
{
|
|
||||||
return ApiError::response(
|
|
||||||
'task_limit_exceeded',
|
|
||||||
__('api.packages.task_limit_exceeded.title'),
|
|
||||||
__('api.packages.task_limit_exceeded.message'),
|
|
||||||
Response::HTTP_PAYMENT_REQUIRED,
|
|
||||||
[
|
|
||||||
'scope' => 'tasks',
|
|
||||||
'used' => $limitStatus['used'],
|
|
||||||
'limit' => $limitStatus['limit'],
|
|
||||||
'remaining' => $limitStatus['remaining'] ?? 0,
|
|
||||||
'event_id' => $event->id,
|
|
||||||
'package_id' => $limitStatus['package_id'],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function prepareTaskPayload(array $data, int $tenantId, ?Task $original = null): array
|
protected function prepareTaskPayload(array $data, int $tenantId, ?Task $original = null): array
|
||||||
{
|
{
|
||||||
if (array_key_exists('title', $data)) {
|
if (array_key_exists('title', $data)) {
|
||||||
|
|||||||
@@ -39,9 +39,7 @@ class TenantPackageController extends Controller
|
|||||||
|
|
||||||
$activePackage = $tenant->activeResellerPackage?->load('package');
|
$activePackage = $tenant->activeResellerPackage?->load('package');
|
||||||
|
|
||||||
if (! ($activePackage instanceof TenantPackage)) {
|
if ($activePackage instanceof TenantPackage) {
|
||||||
$activePackage = $packages->firstWhere('active', true);
|
|
||||||
} else {
|
|
||||||
$this->hydratePackageSnapshot($activePackage, $usageEventPackage);
|
$this->hydratePackageSnapshot($activePackage, $usageEventPackage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +60,6 @@ class TenantPackageController extends Controller
|
|||||||
$pkg?->limits ?? [],
|
$pkg?->limits ?? [],
|
||||||
$this->buildUsageSnapshot($eventPackage),
|
$this->buildUsageSnapshot($eventPackage),
|
||||||
[
|
[
|
||||||
'included_package_slug' => $pkg?->included_package_slug,
|
|
||||||
'branding_allowed' => $pkg?->branding_allowed,
|
'branding_allowed' => $pkg?->branding_allowed,
|
||||||
'watermark_allowed' => $pkg?->watermark_allowed,
|
'watermark_allowed' => $pkg?->watermark_allowed,
|
||||||
'features' => $pkg?->features ?? [],
|
'features' => $pkg?->features ?? [],
|
||||||
|
|||||||
@@ -47,15 +47,6 @@ class AuthenticatedSessionController extends Controller
|
|||||||
|
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
if ($user && $user->email_verified_at === null) {
|
if ($user && $user->email_verified_at === null) {
|
||||||
$intended = $request->session()->get('url.intended');
|
|
||||||
$intended = is_string($intended) ? trim($intended) : null;
|
|
||||||
|
|
||||||
if ($this->isVerificationLink($intended)) {
|
|
||||||
$request->session()->forget('url.intended');
|
|
||||||
|
|
||||||
return Inertia::location($intended);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Inertia::location(route('verification.notice'));
|
return Inertia::location(route('verification.notice'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,29 +116,6 @@ class AuthenticatedSessionController extends Controller
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isVerificationLink(?string $target): bool
|
|
||||||
{
|
|
||||||
if (! is_string($target) || trim($target) === '') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$path = trim($target);
|
|
||||||
|
|
||||||
if (str_starts_with($path, '/verify-email/')) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$parsed = parse_url($path);
|
|
||||||
|
|
||||||
if ($parsed === false) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$path = $parsed['path'] ?? '';
|
|
||||||
|
|
||||||
return $path !== '' && str_starts_with($path, '/verify-email/');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function decodeReturnTo(string $value, Request $request): ?string
|
private function decodeReturnTo(string $value, Request $request): ?string
|
||||||
{
|
{
|
||||||
$candidate = $this->decodeBase64Url($value) ?? $value;
|
$candidate = $this->decodeBase64Url($value) ?? $value;
|
||||||
@@ -157,10 +125,6 @@ class AuthenticatedSessionController extends Controller
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str_starts_with($candidate, '//')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_starts_with($candidate, '/')) {
|
if (str_starts_with($candidate, '/')) {
|
||||||
return $candidate;
|
return $candidate;
|
||||||
}
|
}
|
||||||
@@ -174,7 +138,7 @@ class AuthenticatedSessionController extends Controller
|
|||||||
|
|
||||||
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
||||||
|
|
||||||
if (! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
|
if ($appHost && ! Str::endsWith($targetHost, $appHost)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +190,7 @@ class AuthenticatedSessionController extends Controller
|
|||||||
$scheme = $parsed['scheme'] ?? null;
|
$scheme = $parsed['scheme'] ?? null;
|
||||||
$requestHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
$requestHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
||||||
|
|
||||||
if ($scheme && $host && $requestHost && ! $this->isAllowedReturnHost($host, $requestHost)) {
|
if ($scheme && $host && $requestHost && ! Str::endsWith($host, $requestHost)) {
|
||||||
return '/event-admin/dashboard';
|
return '/event-admin/dashboard';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,15 +233,6 @@ class AuthenticatedSessionController extends Controller
|
|||||||
return $decoded;
|
return $decoded;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isAllowedReturnHost(string $targetHost, string $appHost): bool
|
|
||||||
{
|
|
||||||
if ($targetHost === $appHost) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Str::endsWith($targetHost, '.'.$appHost);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function rememberTenantAdminTarget(Request $request, ?string $target): void
|
private function rememberTenantAdminTarget(Request $request, ?string $target): void
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|||||||
@@ -48,9 +48,6 @@ class CheckoutController extends Controller
|
|||||||
$googleStatus = session()->pull('checkout_google_status');
|
$googleStatus = session()->pull('checkout_google_status');
|
||||||
$googleError = session()->pull('checkout_google_error');
|
$googleError = session()->pull('checkout_google_error');
|
||||||
$googleProfile = session()->pull('checkout_google_profile');
|
$googleProfile = session()->pull('checkout_google_profile');
|
||||||
$facebookStatus = session()->pull('checkout_facebook_status');
|
|
||||||
$facebookError = session()->pull('checkout_facebook_error');
|
|
||||||
$facebookProfile = session()->pull('checkout_facebook_profile');
|
|
||||||
|
|
||||||
$packageOptions = Package::orderBy('price')->get()
|
$packageOptions = Package::orderBy('price')->get()
|
||||||
->map(fn (Package $pkg) => $this->presentPackage($pkg))
|
->map(fn (Package $pkg) => $this->presentPackage($pkg))
|
||||||
@@ -69,11 +66,6 @@ class CheckoutController extends Controller
|
|||||||
'error' => $googleError,
|
'error' => $googleError,
|
||||||
'profile' => $googleProfile,
|
'profile' => $googleProfile,
|
||||||
],
|
],
|
||||||
'facebookAuth' => [
|
|
||||||
'status' => $facebookStatus,
|
|
||||||
'error' => $facebookError,
|
|
||||||
'profile' => $facebookProfile,
|
|
||||||
],
|
|
||||||
'paddle' => [
|
'paddle' => [
|
||||||
'environment' => config('paddle.environment'),
|
'environment' => config('paddle.environment'),
|
||||||
'client_token' => config('paddle.client_token'),
|
'client_token' => config('paddle.client_token'),
|
||||||
@@ -116,8 +108,8 @@ class CheckoutController extends Controller
|
|||||||
'settings' => json_encode([
|
'settings' => json_encode([
|
||||||
'branding' => [
|
'branding' => [
|
||||||
'logo_url' => null,
|
'logo_url' => null,
|
||||||
'primary_color' => '#FF5A5F',
|
'primary_color' => '#3B82F6',
|
||||||
'secondary_color' => '#FFF8F5',
|
'secondary_color' => '#1F2937',
|
||||||
'font_family' => 'Inter, sans-serif',
|
'font_family' => 'Inter, sans-serif',
|
||||||
],
|
],
|
||||||
'features' => [
|
'features' => [
|
||||||
|
|||||||
@@ -1,217 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\Package;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Support\CheckoutRoutes;
|
|
||||||
use App\Support\LocaleConfig;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Laravel\Socialite\Facades\Socialite;
|
|
||||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
|
||||||
|
|
||||||
class CheckoutFacebookController extends Controller
|
|
||||||
{
|
|
||||||
private const SESSION_KEY = 'checkout_facebook_payload';
|
|
||||||
|
|
||||||
public function redirect(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'package_id' => ['required', 'exists:packages,id'],
|
|
||||||
'locale' => ['nullable', 'string'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$payload = [
|
|
||||||
'package_id' => (int) $validated['package_id'],
|
|
||||||
'locale' => $validated['locale'] ?? app()->getLocale(),
|
|
||||||
];
|
|
||||||
|
|
||||||
$request->session()->put(self::SESSION_KEY, $payload);
|
|
||||||
$request->session()->put('selected_package_id', $payload['package_id']);
|
|
||||||
|
|
||||||
return Socialite::driver('facebook')
|
|
||||||
->redirectUrl(route('checkout.facebook.callback'))
|
|
||||||
->scopes(['email'])
|
|
||||||
->fields(['name', 'email', 'first_name', 'last_name'])
|
|
||||||
->redirect();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function callback(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$payload = $request->session()->get(self::SESSION_KEY, []);
|
|
||||||
$packageId = $payload['package_id'] ?? null;
|
|
||||||
$locale = $payload['locale'] ?? null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
$facebookUser = Socialite::driver('facebook')->user();
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Log::warning('Facebook checkout login failed', ['message' => $e->getMessage()]);
|
|
||||||
$this->flashError($request, __('checkout.facebook_error_fallback'));
|
|
||||||
|
|
||||||
return $this->redirectBackToWizard($packageId, $locale);
|
|
||||||
}
|
|
||||||
|
|
||||||
$email = $facebookUser->getEmail();
|
|
||||||
if (! $email) {
|
|
||||||
$this->flashError($request, __('checkout.facebook_missing_email'));
|
|
||||||
|
|
||||||
return $this->redirectBackToWizard($packageId, $locale);
|
|
||||||
}
|
|
||||||
|
|
||||||
$raw = $facebookUser->getRaw();
|
|
||||||
$givenName = $raw['first_name'] ?? null;
|
|
||||||
$familyName = $raw['last_name'] ?? null;
|
|
||||||
$request->session()->put('checkout_facebook_profile', array_filter([
|
|
||||||
'email' => $email,
|
|
||||||
'name' => $facebookUser->getName(),
|
|
||||||
'given_name' => $givenName,
|
|
||||||
'family_name' => $familyName,
|
|
||||||
'avatar' => $facebookUser->getAvatar(),
|
|
||||||
'locale' => $raw['locale'] ?? null,
|
|
||||||
]));
|
|
||||||
|
|
||||||
$existing = User::where('email', $email)->first();
|
|
||||||
|
|
||||||
if (! $existing) {
|
|
||||||
$request->session()->put('checkout_facebook_profile', array_filter([
|
|
||||||
'email' => $email,
|
|
||||||
'name' => $facebookUser->getName(),
|
|
||||||
'given_name' => $givenName,
|
|
||||||
'family_name' => $familyName,
|
|
||||||
'avatar' => $facebookUser->getAvatar(),
|
|
||||||
'locale' => $raw['locale'] ?? null,
|
|
||||||
]));
|
|
||||||
|
|
||||||
$request->session()->put('checkout_facebook_status', 'prefill');
|
|
||||||
|
|
||||||
return $this->redirectBackToWizard($packageId, $locale);
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = DB::transaction(function () use ($existing, $facebookUser, $email) {
|
|
||||||
$existing->forceFill([
|
|
||||||
'name' => $facebookUser->getName() ?: $existing->name,
|
|
||||||
'pending_purchase' => true,
|
|
||||||
'email_verified_at' => $existing->email_verified_at ?? now(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
if (! $existing->tenant) {
|
|
||||||
$this->createTenantForUser($existing, $facebookUser->getName(), $email);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $existing->fresh();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (! $user->tenant) {
|
|
||||||
$this->createTenantForUser($user, $facebookUser->getName(), $email);
|
|
||||||
}
|
|
||||||
|
|
||||||
Auth::login($user, true);
|
|
||||||
$request->session()->regenerate();
|
|
||||||
$request->session()->forget(self::SESSION_KEY);
|
|
||||||
$request->session()->forget('checkout_facebook_profile');
|
|
||||||
$request->session()->put('checkout_facebook_status', 'signin');
|
|
||||||
|
|
||||||
if ($packageId) {
|
|
||||||
$this->ensurePackageAttached($user, (int) $packageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->redirectBackToWizard($packageId, $locale);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createTenantForUser(User $user, ?string $displayName, string $email): Tenant
|
|
||||||
{
|
|
||||||
$tenantName = trim($displayName ?: Str::before($email, '@')) ?: 'Fotospiel Tenant';
|
|
||||||
$slugBase = Str::slug($tenantName) ?: 'tenant';
|
|
||||||
$slug = $slugBase;
|
|
||||||
$counter = 1;
|
|
||||||
|
|
||||||
while (Tenant::where('slug', $slug)->exists()) {
|
|
||||||
$slug = $slugBase.'-'.$counter;
|
|
||||||
$counter++;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::create([
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'name' => $tenantName,
|
|
||||||
'slug' => $slug,
|
|
||||||
'email' => $email,
|
|
||||||
'contact_email' => $email,
|
|
||||||
'is_active' => true,
|
|
||||||
'is_suspended' => false,
|
|
||||||
'subscription_tier' => 'free',
|
|
||||||
'subscription_status' => 'free',
|
|
||||||
'subscription_expires_at' => null,
|
|
||||||
'settings' => json_encode([
|
|
||||||
'branding' => [
|
|
||||||
'logo_url' => null,
|
|
||||||
'primary_color' => '#FF5A5F',
|
|
||||||
'secondary_color' => '#FFF8F5',
|
|
||||||
'font_family' => 'Inter, sans-serif',
|
|
||||||
],
|
|
||||||
'features' => [
|
|
||||||
'photo_likes_enabled' => false,
|
|
||||||
'event_checklist' => false,
|
|
||||||
'custom_domain' => false,
|
|
||||||
'advanced_analytics' => false,
|
|
||||||
],
|
|
||||||
'custom_domain' => null,
|
|
||||||
'contact_email' => $email,
|
|
||||||
'event_default_type' => 'general',
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
|
||||||
|
|
||||||
return $tenant;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function ensurePackageAttached(User $user, int $packageId): void
|
|
||||||
{
|
|
||||||
$tenant = $user->tenant;
|
|
||||||
if (! $tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$package = Package::find($packageId);
|
|
||||||
if (! $package) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($tenant->packages()->where('package_id', $packageId)->exists()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant->packages()->attach($packageId, [
|
|
||||||
'price' => $package->price,
|
|
||||||
'purchased_at' => now(),
|
|
||||||
'expires_at' => now()->addYear(),
|
|
||||||
'active' => $package->price <= 0,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function redirectBackToWizard(?int $packageId, ?string $locale = null): RedirectResponse
|
|
||||||
{
|
|
||||||
if ($packageId) {
|
|
||||||
return redirect()->to(CheckoutRoutes::wizardUrl($packageId, $locale));
|
|
||||||
}
|
|
||||||
|
|
||||||
$firstPackageId = Package::query()->orderBy('price')->value('id');
|
|
||||||
if ($firstPackageId) {
|
|
||||||
return redirect()->to(CheckoutRoutes::wizardUrl($firstPackageId, $locale));
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->route('packages', [
|
|
||||||
'locale' => LocaleConfig::canonicalize($locale ?? app()->getLocale()),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function flashError(Request $request, string $message): void
|
|
||||||
{
|
|
||||||
$request->session()->flash('checkout_facebook_error', $message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -35,7 +35,6 @@ class CheckoutGoogleController extends Controller
|
|||||||
$request->session()->put('selected_package_id', $payload['package_id']);
|
$request->session()->put('selected_package_id', $payload['package_id']);
|
||||||
|
|
||||||
return Socialite::driver('google')
|
return Socialite::driver('google')
|
||||||
->redirectUrl(route('checkout.google.callback'))
|
|
||||||
->scopes(['email', 'profile'])
|
->scopes(['email', 'profile'])
|
||||||
->with(['prompt' => 'select_account'])
|
->with(['prompt' => 'select_account'])
|
||||||
->redirect();
|
->redirect();
|
||||||
@@ -147,8 +146,8 @@ class CheckoutGoogleController extends Controller
|
|||||||
'settings' => json_encode([
|
'settings' => json_encode([
|
||||||
'branding' => [
|
'branding' => [
|
||||||
'logo_url' => null,
|
'logo_url' => null,
|
||||||
'primary_color' => '#FF5A5F',
|
'primary_color' => '#3B82F6',
|
||||||
'secondary_color' => '#FFF8F5',
|
'secondary_color' => '#1F2937',
|
||||||
'font_family' => 'Inter, sans-serif',
|
'font_family' => 'Inter, sans-serif',
|
||||||
],
|
],
|
||||||
'features' => [
|
'features' => [
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class LegalPageController extends Controller
|
|||||||
$effectiveFrom = optional($page->effective_from);
|
$effectiveFrom = optional($page->effective_from);
|
||||||
|
|
||||||
return Inertia::render('legal/Show', [
|
return Inertia::render('legal/Show', [
|
||||||
'seoTitle' => $title.' - '.config('app.name', 'Fotospiel'),
|
'seoTitle' => $title . ' - ' . config('app.name', 'Fotospiel'),
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'content' => $this->convertMarkdownToHtml($bodyMarkdown),
|
'content' => $this->convertMarkdownToHtml($bodyMarkdown),
|
||||||
'effectiveFrom' => $effectiveFrom ? $effectiveFrom->toDateString() : null,
|
'effectiveFrom' => $effectiveFrom ? $effectiveFrom->toDateString() : null,
|
||||||
@@ -112,11 +112,11 @@ class LegalPageController extends Controller
|
|||||||
'allow_unsafe_links' => false,
|
'allow_unsafe_links' => false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$environment->addExtension(new CommonMarkCoreExtension);
|
$environment->addExtension(new CommonMarkCoreExtension());
|
||||||
$environment->addExtension(new TableExtension);
|
$environment->addExtension(new TableExtension());
|
||||||
$environment->addExtension(new AutolinkExtension);
|
$environment->addExtension(new AutolinkExtension());
|
||||||
$environment->addExtension(new StrikethroughExtension);
|
$environment->addExtension(new StrikethroughExtension());
|
||||||
$environment->addExtension(new TaskListExtension);
|
$environment->addExtension(new TaskListExtension());
|
||||||
|
|
||||||
$converter = new MarkdownConverter($environment);
|
$converter = new MarkdownConverter($environment);
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ class MarketingController extends Controller
|
|||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'email' => 'required|email|max:255',
|
'email' => 'required|email|max:255',
|
||||||
'message' => 'required|string|max:1000',
|
'message' => 'required|string|max:1000',
|
||||||
|
'nickname' => 'present|size:0',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$locale = app()->getLocale();
|
$locale = app()->getLocale();
|
||||||
@@ -408,17 +409,10 @@ class MarketingController extends Controller
|
|||||||
|
|
||||||
public function demo()
|
public function demo()
|
||||||
{
|
{
|
||||||
$event = Event::query()
|
$joinToken = optional(Event::firstWhere('slug', 'demo-wedding-2025'))
|
||||||
->where('settings->marketing_demo', true)
|
?->joinTokens()
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
$joinToken = null;
|
|
||||||
|
|
||||||
if ($event) {
|
|
||||||
$joinToken = $event->joinTokens()
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
$demoToken = null;
|
$demoToken = null;
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class EventPhotoArchiveController extends Controller
|
|||||||
abort(404, 'No approved photos available for this event.');
|
abort(404, 'No approved photos available for this event.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$zip = new ZipArchive;
|
$zip = new ZipArchive();
|
||||||
$tempPath = tempnam(sys_get_temp_dir(), 'fotospiel-photos-');
|
$tempPath = tempnam(sys_get_temp_dir(), 'fotospiel-photos-');
|
||||||
|
|
||||||
if ($tempPath === false || $zip->open($tempPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
if ($tempPath === false || $zip->open($tempPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||||
@@ -129,3 +129,4 @@ class EventPhotoArchiveController extends Controller
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Laravel\Socialite\Facades\Socialite;
|
|
||||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class TenantAdminFacebookController extends Controller
|
|
||||||
{
|
|
||||||
public function redirect(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$returnTo = $request->query('return_to');
|
|
||||||
if (is_string($returnTo) && $returnTo !== '') {
|
|
||||||
$request->session()->put('tenant_oauth_return_to', $returnTo);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Socialite::driver('facebook')
|
|
||||||
->redirectUrl(route('tenant.admin.facebook.callback'))
|
|
||||||
->scopes(['email'])
|
|
||||||
->fields(['name', 'email', 'first_name', 'last_name'])
|
|
||||||
->redirect();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function callback(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$facebookUser = Socialite::driver('facebook')->user();
|
|
||||||
} catch (Throwable $exception) {
|
|
||||||
Log::warning('Tenant admin Facebook sign-in failed', [
|
|
||||||
'message' => $exception->getMessage(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $this->sendBackWithError($request, 'facebook_failed', 'Unable to complete Facebook sign-in.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$email = $facebookUser->getEmail();
|
|
||||||
if (! $email) {
|
|
||||||
return $this->sendBackWithError($request, 'facebook_failed', 'Facebook account did not provide an email address.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var User|null $user */
|
|
||||||
$user = User::query()->where('email', $email)->first();
|
|
||||||
|
|
||||||
if (! $user || ! in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
|
|
||||||
return $this->sendBackWithError($request, 'facebook_no_match', 'No tenant admin account is linked to this Facebook address.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$user->forceFill([
|
|
||||||
'name' => $facebookUser->getName() ?: $user->name,
|
|
||||||
'email_verified_at' => $user->email_verified_at ?? now(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
Auth::login($user, true);
|
|
||||||
$request->session()->regenerate();
|
|
||||||
$request->session()->forget('url.intended');
|
|
||||||
|
|
||||||
$returnTo = $request->session()->pull('tenant_oauth_return_to');
|
|
||||||
if (is_string($returnTo)) {
|
|
||||||
$decoded = $this->decodeReturnTo($returnTo, $request);
|
|
||||||
if ($decoded) {
|
|
||||||
return redirect()->to($decoded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$fallback = $request->session()->pull('tenant_admin.return_to');
|
|
||||||
if (is_string($fallback) && str_starts_with($fallback, '/event-admin')) {
|
|
||||||
return redirect()->to($fallback);
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->to('/event-admin/dashboard');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function sendBackWithError(Request $request, string $code, string $message): RedirectResponse
|
|
||||||
{
|
|
||||||
$query = [
|
|
||||||
'error' => $code,
|
|
||||||
'error_description' => $message,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($request->session()->has('tenant_oauth_return_to')) {
|
|
||||||
$query['return_to'] = $request->session()->get('tenant_oauth_return_to');
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->route('tenant.admin.login', $query);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function decodeReturnTo(string $encoded, Request $request): ?string
|
|
||||||
{
|
|
||||||
$padded = str_pad($encoded, strlen($encoded) + ((4 - (strlen($encoded) % 4)) % 4), '=');
|
|
||||||
$normalized = strtr($padded, '-_', '+/');
|
|
||||||
$decoded = base64_decode($normalized);
|
|
||||||
|
|
||||||
if (! is_string($decoded) || $decoded === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_starts_with($decoded, '//')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_starts_with($decoded, '/')) {
|
|
||||||
return $decoded;
|
|
||||||
}
|
|
||||||
|
|
||||||
$targetHost = parse_url($decoded, PHP_URL_HOST);
|
|
||||||
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
|
||||||
|
|
||||||
if (! $targetHost || ! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $decoded;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isAllowedReturnHost(string $targetHost, string $appHost): bool
|
|
||||||
{
|
|
||||||
if ($targetHost === $appHost) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Str::endsWith($targetHost, '.'.$appHost);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,6 @@ class TenantAdminGoogleController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Socialite::driver('google')
|
return Socialite::driver('google')
|
||||||
->redirectUrl(route('tenant.admin.google.callback'))
|
|
||||||
->scopes(['openid', 'profile', 'email'])
|
->scopes(['openid', 'profile', 'email'])
|
||||||
->with(['prompt' => 'select_account'])
|
->with(['prompt' => 'select_account'])
|
||||||
->redirect();
|
->redirect();
|
||||||
@@ -58,7 +57,6 @@ class TenantAdminGoogleController extends Controller
|
|||||||
|
|
||||||
Auth::login($user, true);
|
Auth::login($user, true);
|
||||||
$request->session()->regenerate();
|
$request->session()->regenerate();
|
||||||
$request->session()->forget('url.intended');
|
|
||||||
|
|
||||||
$returnTo = $request->session()->pull('tenant_oauth_return_to');
|
$returnTo = $request->session()->pull('tenant_oauth_return_to');
|
||||||
if (is_string($returnTo)) {
|
if (is_string($returnTo)) {
|
||||||
@@ -68,12 +66,7 @@ class TenantAdminGoogleController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$fallback = $request->session()->pull('tenant_admin.return_to');
|
return redirect()->intended('/event-admin/dashboard');
|
||||||
if (is_string($fallback) && str_starts_with($fallback, '/event-admin')) {
|
|
||||||
return redirect()->to($fallback);
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->to('/event-admin/dashboard');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function sendBackWithError(Request $request, string $code, string $message): RedirectResponse
|
private function sendBackWithError(Request $request, string $code, string $message): RedirectResponse
|
||||||
@@ -100,30 +93,13 @@ class TenantAdminGoogleController extends Controller
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str_starts_with($decoded, '//')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_starts_with($decoded, '/')) {
|
|
||||||
return $decoded;
|
|
||||||
}
|
|
||||||
|
|
||||||
$targetHost = parse_url($decoded, PHP_URL_HOST);
|
$targetHost = parse_url($decoded, PHP_URL_HOST);
|
||||||
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
||||||
|
|
||||||
if (! $targetHost || ! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
|
if ($targetHost && $appHost && ! Str::endsWith($targetHost, $appHost)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $decoded;
|
return $decoded;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isAllowedReturnHost(string $targetHost, string $appHost): bool
|
|
||||||
{
|
|
||||||
if ($targetHost === $appHost) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Str::endsWith($targetHost, '.'.$appHost);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ class TestGuestEventController extends Controller
|
|||||||
'date' => ($validated['date'] ?? Carbon::now()->addWeeks(2)->toDateString()),
|
'date' => ($validated['date'] ?? Carbon::now()->addWeeks(2)->toDateString()),
|
||||||
'settings' => [
|
'settings' => [
|
||||||
'branding' => [
|
'branding' => [
|
||||||
'primary_color' => '#FF5A5F',
|
'primary_color' => '#f43f5e',
|
||||||
'secondary_color' => '#FFF8F5',
|
'secondary_color' => '#fb7185',
|
||||||
'font_family' => 'Inter, sans-serif',
|
'font_family' => 'Inter, sans-serif',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -118,18 +118,11 @@ class ContentSecurityPolicy
|
|||||||
$styleSources[] = 'data:';
|
$styleSources[] = 'data:';
|
||||||
$connectSources[] = 'https:';
|
$connectSources[] = 'https:';
|
||||||
$fontSources[] = 'https:';
|
$fontSources[] = 'https:';
|
||||||
$styleElemSources = array_values(array_filter(
|
|
||||||
$styleSources,
|
|
||||||
static fn (string $source): bool => ! str_starts_with($source, "'nonce-")
|
|
||||||
));
|
|
||||||
$styleElemSources = array_unique(array_merge($styleElemSources, ["'unsafe-inline'"]));
|
|
||||||
|
|
||||||
$directives = [
|
$directives = [
|
||||||
'default-src' => ["'self'"],
|
'default-src' => ["'self'"],
|
||||||
'script-src' => array_unique($scriptSources),
|
'script-src' => array_unique($scriptSources),
|
||||||
'style-src' => array_unique($styleSources),
|
'style-src' => array_unique($styleSources),
|
||||||
'style-src-elem' => $styleElemSources,
|
|
||||||
'style-src-attr' => ["'unsafe-inline'"],
|
|
||||||
'img-src' => array_unique($imgSources),
|
'img-src' => array_unique($imgSources),
|
||||||
'font-src' => array_unique($fontSources),
|
'font-src' => array_unique($fontSources),
|
||||||
'connect-src' => array_unique($connectSources),
|
'connect-src' => array_unique($connectSources),
|
||||||
|
|||||||
@@ -28,12 +28,7 @@ class CreditCheckMiddleware
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($this->requiresCredits($request) && ! $this->shouldBypassCreditCheck($request, $tenant)) {
|
if ($this->requiresCredits($request) && ! $this->shouldBypassCreditCheck($request, $tenant)) {
|
||||||
$includedSlug = $request->input('service_package_slug');
|
$violation = $this->limitEvaluator->assessEventCreation($tenant);
|
||||||
|
|
||||||
$violation = $this->limitEvaluator->assessEventCreation(
|
|
||||||
$tenant,
|
|
||||||
is_string($includedSlug) && $includedSlug !== '' ? $includedSlug : null
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($violation !== null) {
|
if ($violation !== null) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
|
||||||
|
|
||||||
use App\Support\ApiError;
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Laravel\Sanctum\PersonalAccessToken;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
|
|
||||||
class EnsureSupportToken
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Handle an incoming request.
|
|
||||||
*/
|
|
||||||
public function handle(Request $request, Closure $next): JsonResponse|Response
|
|
||||||
{
|
|
||||||
$user = $request->user();
|
|
||||||
|
|
||||||
if (! $user) {
|
|
||||||
return $this->unauthorizedResponse('Unauthenticated request.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$accessToken = $user->currentAccessToken();
|
|
||||||
|
|
||||||
if (! $accessToken instanceof PersonalAccessToken) {
|
|
||||||
return $this->unauthorizedResponse('Missing personal access token context.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->isSuperAdmin()) {
|
|
||||||
return $this->forbiddenResponse('Only super administrators may access support APIs.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $accessToken->can('support-admin') && ! $accessToken->can('super-admin')) {
|
|
||||||
return $this->forbiddenResponse('Access token does not include the support-admin ability.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$request->attributes->set('support_token_id', $accessToken->id);
|
|
||||||
|
|
||||||
Auth::shouldUse('sanctum');
|
|
||||||
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function unauthorizedResponse(string $message): JsonResponse
|
|
||||||
{
|
|
||||||
return ApiError::response(
|
|
||||||
'unauthenticated',
|
|
||||||
'Unauthenticated',
|
|
||||||
$message,
|
|
||||||
Response::HTTP_UNAUTHORIZED
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function forbiddenResponse(string $message): JsonResponse
|
|
||||||
{
|
|
||||||
return ApiError::response(
|
|
||||||
'support_forbidden',
|
|
||||||
'Forbidden',
|
|
||||||
$message,
|
|
||||||
Response::HTTP_FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ use App\Support\LocaleConfig;
|
|||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Middleware;
|
use Inertia\Middleware;
|
||||||
use Spatie\Honeypot\Honeypot;
|
|
||||||
|
|
||||||
class HandleInertiaRequests extends Middleware
|
class HandleInertiaRequests extends Middleware
|
||||||
{
|
{
|
||||||
@@ -68,7 +67,6 @@ class HandleInertiaRequests extends Middleware
|
|||||||
'error' => fn () => $request->session()->get('error'),
|
'error' => fn () => $request->session()->get('error'),
|
||||||
'verification' => fn () => $request->session()->get('verification'),
|
'verification' => fn () => $request->session()->get('verification'),
|
||||||
],
|
],
|
||||||
'honeypot' => fn () => new Honeypot(config('honeypot')),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,12 +73,7 @@ class PackageMiddleware
|
|||||||
private function detectViolation(Request $request, Tenant $tenant): ?array
|
private function detectViolation(Request $request, Tenant $tenant): ?array
|
||||||
{
|
{
|
||||||
if ($request->routeIs('api.v1.tenant.events.store')) {
|
if ($request->routeIs('api.v1.tenant.events.store')) {
|
||||||
$includedSlug = $request->input('service_package_slug');
|
return $this->limitEvaluator->assessEventCreation($tenant);
|
||||||
|
|
||||||
return $this->limitEvaluator->assessEventCreation(
|
|
||||||
$tenant,
|
|
||||||
is_string($includedSlug) && $includedSlug !== '' ? $includedSlug : null
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->routeIs('api.v1.tenant.events.photos.store')) {
|
if ($request->routeIs('api.v1.tenant.events.photos.store')) {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class SetLocale
|
|||||||
$sessionLocale = Session::get('locale', 'de');
|
$sessionLocale = Session::get('locale', 'de');
|
||||||
|
|
||||||
// Fallback to Accept-Language header if no session
|
// Fallback to Accept-Language header if no session
|
||||||
if (! in_array($sessionLocale, $supportedLocales)) {
|
if (!in_array($sessionLocale, $supportedLocales)) {
|
||||||
$acceptLanguage = $request->header('Accept-Language', 'de');
|
$acceptLanguage = $request->header('Accept-Language', 'de');
|
||||||
$localeFromHeader = substr($acceptLanguage, 0, 2);
|
$localeFromHeader = substr($acceptLanguage, 0, 2);
|
||||||
$sessionLocale = in_array($localeFromHeader, $supportedLocales) ? $localeFromHeader : 'de';
|
$sessionLocale = in_array($localeFromHeader, $supportedLocales) ? $localeFromHeader : 'de';
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
use App\Support\LocaleConfig;
|
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\App;
|
use Illuminate\Support\Facades\App;
|
||||||
use Illuminate\Support\Facades\Session;
|
use Illuminate\Support\Facades\Session;
|
||||||
|
use App\Support\LocaleConfig;
|
||||||
|
|
||||||
class SetLocaleFromRequest
|
class SetLocaleFromRequest
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,3 +19,4 @@ class SetLocaleFromUser
|
|||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Requests\Photobooth;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class PhotoboothSendUploaderDownloadRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -57,3 +57,8 @@ class ProfileUpdateRequest extends FormRequest
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Requests\Support\Resources;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class SupportBlogPostResourceRequest extends SupportResourceFormRequest
|
|
||||||
{
|
|
||||||
public static function rulesFor(string $action, ?Model $model = null): array
|
|
||||||
{
|
|
||||||
$postId = $model?->getKey();
|
|
||||||
|
|
||||||
$rules = [
|
|
||||||
'blog_category_id' => ['sometimes', 'integer', 'exists:blog_categories,id'],
|
|
||||||
'slug' => [
|
|
||||||
'sometimes',
|
|
||||||
'string',
|
|
||||||
'max:255',
|
|
||||||
Rule::unique('blog_posts', 'slug')->ignore($postId),
|
|
||||||
],
|
|
||||||
'banner' => ['sometimes', 'nullable', 'string', 'max:255'],
|
|
||||||
'published_at' => ['sometimes', 'nullable', 'date'],
|
|
||||||
'is_published' => ['sometimes', 'boolean'],
|
|
||||||
'title' => ['sometimes', 'array'],
|
|
||||||
'title.de' => ['required_with:title', 'string', 'max:255'],
|
|
||||||
'title.en' => ['nullable', 'string', 'max:255'],
|
|
||||||
'content' => ['sometimes', 'array'],
|
|
||||||
'content.de' => ['required_with:content', 'string'],
|
|
||||||
'content.en' => ['nullable', 'string'],
|
|
||||||
'excerpt' => ['sometimes', 'array'],
|
|
||||||
'excerpt.de' => ['nullable', 'string'],
|
|
||||||
'excerpt.en' => ['nullable', 'string'],
|
|
||||||
'meta_title' => ['sometimes', 'array'],
|
|
||||||
'meta_title.de' => ['nullable', 'string', 'max:255'],
|
|
||||||
'meta_title.en' => ['nullable', 'string', 'max:255'],
|
|
||||||
'meta_description' => ['sometimes', 'array'],
|
|
||||||
'meta_description.de' => ['nullable', 'string'],
|
|
||||||
'meta_description.en' => ['nullable', 'string'],
|
|
||||||
'translations' => ['sometimes', 'array'],
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($action === 'create') {
|
|
||||||
$rules['blog_category_id'] = ['required', 'integer', 'exists:blog_categories,id'];
|
|
||||||
$rules['slug'] = ['required', 'string', 'max:255', Rule::unique('blog_posts', 'slug')];
|
|
||||||
$rules['title'] = ['required', 'array'];
|
|
||||||
$rules['title.de'] = ['required', 'string', 'max:255'];
|
|
||||||
$rules['content'] = ['required', 'array'];
|
|
||||||
$rules['content.de'] = ['required', 'string'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $rules;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function allowedFields(string $action): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'blog_category_id',
|
|
||||||
'slug',
|
|
||||||
'banner',
|
|
||||||
'published_at',
|
|
||||||
'is_published',
|
|
||||||
'title',
|
|
||||||
'content',
|
|
||||||
'excerpt',
|
|
||||||
'meta_title',
|
|
||||||
'meta_description',
|
|
||||||
'translations',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user