Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2321e5389d | |||
| 962a5be29f | |||
| 7a8f16c624 | |||
| cc1a3728e5 | |||
| dc46ba0a4b | |||
| 2e19a3c036 | |||
| b0caa50e7c | |||
| 18474d30ab | |||
| b213bcee8b | |||
| 685c6bb2ce | |||
| da9ba3094c | |||
| d7e30ce781 | |||
| 51064d95e3 | |||
| 0bde09ad5c | |||
| 2b5446555b | |||
| 3b571ede07 | |||
| 77d319adb2 | |||
| 20b3889b2d | |||
| ea0951ba85 | |||
| 10974842ff | |||
| e51b284181 | |||
| 012d84b369 | |||
| 290c62ae52 | |||
| bd7618d2e5 | |||
| 23c2e00792 | |||
| 22da268739 | |||
| 30433b7183 | |||
| 02c713f416 | |||
| a6785f26db | |||
| bf2f18f847 |
@@ -1,43 +0,0 @@
|
||||
APP_ENV=production
|
||||
APP_DEBUG=true
|
||||
APP_KEY=base64:ygngELJvE4TbfP8g/mBUl6+QzmWIvFW8Y2cgnADYIUY=
|
||||
APP_URL=http://vanguard.test
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST="localhost"
|
||||
DB_DATABASE="casadocdb"
|
||||
DB_USERNAME="xxx"
|
||||
DB_PASSWORD="xxx"
|
||||
DB_PREFIX="auth_"
|
||||
|
||||
BROADCAST_DRIVER=log
|
||||
CACHE_DRIVER=file
|
||||
QUEUE_DRIVER=sync
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=mail
|
||||
MAIL_FROM_NAME=Vanguard
|
||||
MAIL_FROM_ADDRESS=vanguard@test.dev
|
||||
MAIL_HOST=smtp.mailtrap.io
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=null
|
||||
|
||||
PUSHER_APP_ID=
|
||||
PUSHER_APP_KEY=
|
||||
PUSHER_APP_SECRET=
|
||||
PUSHER_APP_CLUSTER=mt1
|
||||
|
||||
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
|
||||
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
|
||||
|
||||
# ASSETS_BASE_URL=https://casadoc.cesoft.io/public
|
||||
ASSETS_BASE_URL=
|
||||
@@ -1,28 +1,59 @@
|
||||
**/.DS_Store
|
||||
.DS_Store
|
||||
/node_modules
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/vendor
|
||||
/.idea
|
||||
/.fleet
|
||||
/.vscode
|
||||
/.vagrant
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# Environment Variables (Secrets)
|
||||
.env
|
||||
.phpunit.result.cache
|
||||
.php_cs.cache
|
||||
/documentation
|
||||
/.phpunit.cache
|
||||
/public/build
|
||||
.env.backup
|
||||
.env.production
|
||||
auth.json
|
||||
public/Connections/casadocconn.php
|
||||
public/userportal/tools/mailer.php
|
||||
public/userportal/persondocuments/
|
||||
public/userportal/mainphoto/
|
||||
public/userportal/homedocuments/
|
||||
.env.*
|
||||
!.env.example
|
||||
server/.env
|
||||
mobile/.env
|
||||
|
||||
# Server (PHP/Laravel)
|
||||
server/auth.json
|
||||
_ide_helper.php
|
||||
_ide_helper_models.php
|
||||
.phpstorm.meta.php
|
||||
server/.fleet
|
||||
server/.idea
|
||||
server/.php_cs.cache
|
||||
server/.phpunit.cache
|
||||
server/.phpunit.result.cache
|
||||
server/.vagrant
|
||||
server/.vscode
|
||||
server/Homestead.json
|
||||
server/Homestead.yaml
|
||||
server/bootstrap/cache
|
||||
server/documentation
|
||||
server/node_modules
|
||||
server/npm-debug.log
|
||||
server/public/build
|
||||
server/public/hot
|
||||
server/public/storage
|
||||
server/public/userportal
|
||||
server/storage/*.key
|
||||
server/storage/app/private
|
||||
server/storage/debugbar
|
||||
server/storage/debugbar/*
|
||||
server/storage/framework/cache/data
|
||||
server/storage/framework/sessions
|
||||
server/storage/framework/testing
|
||||
server/storage/framework/views
|
||||
server/storage/logs
|
||||
server/vendor
|
||||
|
||||
# Mobile (React Native/Expo)
|
||||
mobile/.expo
|
||||
mobile/android
|
||||
mobile/ios
|
||||
mobile/node_modules
|
||||
mobile/dist
|
||||
mobile/web-build
|
||||
mobile/npm-debug.log
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
# Ignore user-uploaded content
|
||||
server/public/upload
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,41 @@
|
||||
services:
|
||||
# The Application Service (PHP)
|
||||
app:
|
||||
build:
|
||||
context: ./server
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- user=${USER}
|
||||
- uid=${UID}
|
||||
image: casadoc-app
|
||||
container_name: casadoc-app
|
||||
restart: unless-stopped
|
||||
tty: true
|
||||
environment:
|
||||
SERVICE_NAME: app
|
||||
SERVICE_TAGS: dev
|
||||
working_dir: /var/www
|
||||
volumes:
|
||||
- ./server:/var/www
|
||||
- ./docker/php/local.ini:/usr/local/etc/php/conf.d/local.ini
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
# The Web Server (Nginx)
|
||||
webserver:
|
||||
image: nginx:alpine
|
||||
container_name: casadoc-webserver
|
||||
restart: unless-stopped
|
||||
tty: true
|
||||
ports:
|
||||
- "80" # Here port 80 in the container is mapped to any ephemeral port (random Docker chosen port) on the host.
|
||||
volumes:
|
||||
- ./server:/var/www
|
||||
- ./docker/nginx/conf.d/:/etc/nginx/conf.d/
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
# Docker Networks
|
||||
networks:
|
||||
app-network:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,21 @@
|
||||
server {
|
||||
listen 80;
|
||||
client_max_body_size 10M;
|
||||
index index.php index.html;
|
||||
error_log /var/log/nginx/error.log;
|
||||
access_log /var/log/nginx/access.log;
|
||||
root /var/www/public;
|
||||
location ~ \.php$ {
|
||||
try_files $uri =404;
|
||||
fastcgi_split_path_info ^(.+\.php)(/.+)$;
|
||||
fastcgi_pass app:9000;
|
||||
fastcgi_index index.php;
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
fastcgi_param PATH_INFO $fastcgi_path_info;
|
||||
}
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
gzip_static on;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
upload_max_filesize=100M
|
||||
post_max_size=100M
|
||||
@@ -0,0 +1,141 @@
|
||||
## Casadoc: Technical specifications describing cross-platform feasibility
|
||||
|
||||
##### Summary
|
||||
|
||||
The strategic choice, considering high quality UI/UX and performance over a quick-fix migration (using a framework like Ionic), is **React Native**. While it requires more initial work than Ionic, the result will be a robust mobile app that feels truly native on both iOS and Android. The frontend will have to be written in JavaScript entirely but it offers a significantly faster, smoother *native* feel. The PHP code would only serve as the backend API.
|
||||
|
||||
##### React Native
|
||||
|
||||
* **Architecture:** Renders true native mobile UI components using JavaScript.
|
||||
|
||||
* **Frontend Migration:** We won't be able to use the original HTML/CSS output rendered by the PHP code directly. We'll have to rewrite the UI using JSX (React).
|
||||
|
||||
* **Language:** JavaScript/TypeScript.
|
||||
|
||||
* **Native Features:** React Native supports a large ecosystem of native modules.
|
||||
|
||||
#### Technical strategy for migrating Casadoc from PHP to React Native
|
||||
|
||||
1. **Architectural Shift**
|
||||
|
||||
**Decoupling**. Currently, our PHP code likely mixes logic and UI (e.g., fetching data from DB and immediately rendering an HTML table).
|
||||
|
||||
- **Current State:** PHP $\rightarrow$ HTML/CSS (Browser renders UI)
|
||||
|
||||
- **Future State:** PHP $\rightarrow$ JSON Data $\rightarrow$ React Native (Mobile renders UI)
|
||||
|
||||
2. **Category-Wise Migration Plan**
|
||||
|
||||
- **The Backend (PHP): Transformation to API**
|
||||
|
||||
We do not need to rewrite the backend logic (code), but we must change *how* it delivers data.
|
||||
|
||||
- Create a new set of endpoints specifically for the mobile app.
|
||||
|
||||
- **Authentication:**
|
||||
|
||||
- *Current:* Likely uses Sessions/Cookies (`$_SESSION`).
|
||||
|
||||
- *New:* Switch to **JWT (JSON Web Tokens)**. The app will send a "Token" with every request to prove who the user is.
|
||||
|
||||
- **Response Format:**
|
||||
|
||||
- Stop returning `view('profile.index', $data)`
|
||||
|
||||
- Start returning `json_encode($data)` or `return response()->json($data)`
|
||||
|
||||
- **The Frontend (React Native): Complete Rewrite**
|
||||
|
||||
Most of the work will go into the frontend development since we can't use the HTML/CSS given by PHP directly for rendering.
|
||||
|
||||
**UI Components:**
|
||||
|
||||
- HTML `<div>` becomes `<View>`
|
||||
|
||||
- HTML `<span>` or `<p>` becomes `<Text>`
|
||||
|
||||
- HTML `<img>` becomes `<Image>`
|
||||
|
||||
- HTML `<button>` becomes `<TouchableOpacity>`
|
||||
|
||||
**Styling:**
|
||||
|
||||
- CSS is replaced by **StyleSheet** objects (JavaScript objects that look like CSS).
|
||||
|
||||
**Navigation:**
|
||||
|
||||
- Unlike the web app which uses URLs, mobile app uses Stack/Tab navigation. We'll use a library like **React Navigation** to move between screens.
|
||||
|
||||
- ###### **The Database (DB)**
|
||||
|
||||
- **Master DB:** Stays as MySQL/MariaDB on the server.
|
||||
|
||||
- **Local Storage:** For features like caching, we'll use **AsyncStorage**. For heavy offline data (e.g., viewing documents offline), we'll use **SQLite** on the device.
|
||||
|
||||
3. **Example code comparison**
|
||||
|
||||
A simple "User Profile" card change from PHP to React Native.
|
||||
|
||||
- **Current PHP (Blade/Standard)**
|
||||
|
||||
```php
|
||||
<div class="user-card">
|
||||
<img src="<?php echo $user->avatar; ?>" />
|
||||
<h1><?php echo $user->name; ?></h1>
|
||||
<p><?php echo $user->email; ?></p>
|
||||
</div>
|
||||
```
|
||||
|
||||
- **New React Native (JSX)**
|
||||
|
||||
```javascript
|
||||
// UserProfile.js
|
||||
import React from 'react';
|
||||
import { View, Text, Image, StyleSheet } from 'react-native';
|
||||
|
||||
const UserProfile = ({ user }) => {
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<Image source={{ uri: user.avatar }} style={styles.avatar} />
|
||||
<Text style={styles.name}>{user.name}</Text>
|
||||
<Text style={styles.email}>{user.email}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Styles are defined in JS, not CSS files
|
||||
const styles = StyleSheet.create({
|
||||
card: { padding: 20, backgroundColor: '#fff' },
|
||||
name: { fontSize: 18, fontWeight: 'bold' }
|
||||
});
|
||||
|
||||
export default UserProfile;
|
||||
```
|
||||
|
||||
### Tentative roadmap
|
||||
|
||||
##### **Phase 1: API Foundation (PHP Side)**
|
||||
|
||||
1. **Environment:** Set up a route group (e.g., `/api/v1/`) in the PHP project.
|
||||
|
||||
2. **Auth:** Implement an endpoint `/api/login` that accepts email/password and returns a JWT Token.
|
||||
|
||||
3. **Read-Only Data:** Create endpoints to *fetch* data (e.g., `GET /documents`).
|
||||
|
||||
##### **Phase 2: The Basic Blueprint App (React Native Side)**
|
||||
|
||||
1. **Setup:** Initialize the project using **React Native CLI** (for maximum control) or **Expo**.
|
||||
|
||||
2. **Navigation:** Build the "Skeleton" – Login Screen, Dashboard (List View), and Detail Screen.
|
||||
|
||||
3. **Connection:** Write a service function in JS to `fetch` data from the new PHP API and display it.
|
||||
|
||||
##### **Phase 3: Native Features & Interactivity**
|
||||
|
||||
1. **Camera:** Add a feature to "Scan Document" using a React Native Camera library.
|
||||
|
||||
2. **Upload:** Create a PHP endpoint to accept `POST` file uploads and wire it to the camera.
|
||||
|
||||
3. **Push Notifications:** (*optional*) Integrate Firebase (FCM) to notify users when a document status changes.
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useState } from 'react';
|
||||
import { useColorScheme } from 'react-native';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import AppNavigator from './src/navigation/AppNavigator';
|
||||
import AnimatedSplash from './src/screens/AnimatedSplash';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import * as SystemUI from 'expo-system-ui';
|
||||
import { AppLightTheme, AppDarkTheme } from './src/navigation/themes';
|
||||
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
export default function App() {
|
||||
const [isSplashFinished, setSplashFinished] = useState(false);
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
const backgroundColor = colorScheme === 'dark' ? AppDarkTheme.colors.background : AppLightTheme.colors.background;
|
||||
SystemUI.setBackgroundColorAsync(backgroundColor);
|
||||
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<NavigationContainer theme={colorScheme === 'dark' ? AppDarkTheme : AppLightTheme}>
|
||||
<AppNavigator />
|
||||
<StatusBar style={colorScheme === 'dark' ? "light" : "dark"} />
|
||||
</NavigationContainer>
|
||||
|
||||
{!isSplashFinished && (
|
||||
<AnimatedSplash onFinish={() => setSplashFinished(true)} />
|
||||
)}
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "CasaDoc",
|
||||
"slug": "casadoc-mobile",
|
||||
"scheme": "casadoc",
|
||||
"version": "0.0.1",
|
||||
"orientation": "portrait",
|
||||
"backgroundColor": "#ffffff",
|
||||
"dark": {
|
||||
"backgroundColor": "#000000"
|
||||
},
|
||||
"userInterfaceStyle": "automatic",
|
||||
"splash": {
|
||||
"image": "./assets/icons/splash-icon-dark.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff",
|
||||
"dark": {
|
||||
"image": "./assets/icons/splash-icon-light.png",
|
||||
"backgroundColor": "#1a202c"
|
||||
}
|
||||
},
|
||||
"assetBundlePatterns": [
|
||||
"**/*"
|
||||
],
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"icon": {
|
||||
"dark": "./assets/icons/ios-dark.png",
|
||||
"light": "./assets/icons/ios-light.png",
|
||||
"tinted": "./assets/icons/ios-tinted.png"
|
||||
},
|
||||
"bundleIdentifier": "com.cesoft.casadoc",
|
||||
"appleTeamId": "NBX9G827SH",
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "This app needs access to the camera to scan documents."
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/icons/android-adaptive.png",
|
||||
"monochromeImage": "./assets/icons/android-adaptive.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "com.cesoft.casadoc"
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-web-browser"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,6 @@
|
||||
module.exports = function(api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { registerRootComponent } from 'expo';
|
||||
|
||||
import App from './App';
|
||||
|
||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||
// the environment is set up appropriately
|
||||
registerRootComponent(App);
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "casadoc-mobile",
|
||||
"version": "0.0.1",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"prebuild": "expo prebuild --clean",
|
||||
"start": "expo start --dev-client",
|
||||
"start:go": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^7.12.0",
|
||||
"@react-navigation/native": "^7.1.28",
|
||||
"@react-navigation/native-stack": "^7.12.0",
|
||||
"babel-preset-expo": "^54.0.10",
|
||||
"expo": "^54.0.0",
|
||||
"expo-apple-authentication": "~8.0.8",
|
||||
"expo-auth-session": "~7.0.10",
|
||||
"expo-crypto": "~15.0.8",
|
||||
"expo-file-system": "^19.0.21",
|
||||
"expo-image-manipulator": "^14.0.8",
|
||||
"expo-image-picker": "^17.0.10",
|
||||
"expo-print": "^15.0.8",
|
||||
"expo-sharing": "^14.0.8",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-blob-util": "^0.24.6",
|
||||
"react-native-document-scanner-plugin": "^2.0.4",
|
||||
"react-native-pdf": "^7.0.3",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@types/react": "~19.1.10",
|
||||
"patch-package": "^8.0.1",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"overrides": {
|
||||
"glob": "12.0.0",
|
||||
"rimraf": "4.3.1"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
diff --git a/node_modules/react-native-pdf/index.js b/node_modules/react-native-pdf/index.js
|
||||
index 56df005..8b8f5d2 100644
|
||||
--- a/node_modules/react-native-pdf/index.js
|
||||
+++ b/node_modules/react-native-pdf/index.js
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import PdfViewNativeComponent, {
|
||||
Commands as PdfViewCommands,
|
||||
} from './fabric/RNPDFPdfNativeComponent';
|
||||
-import ReactNativeBlobUtil from 'react-native-blob-util'
|
||||
+import ReactNativeBlobUtil from 'react-native-blob-util';
|
||||
import {ViewPropTypes} from 'deprecated-react-native-prop-types';
|
||||
const SHA1 = require('crypto-js/sha1');
|
||||
import PdfView from './PdfView';
|
||||
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, Platform, useColorScheme, Image } from 'react-native';
|
||||
import { FontAwesome } from '@expo/vector-icons';
|
||||
|
||||
interface SocialButtonsProps {
|
||||
onGooglePress: () => void;
|
||||
onFacebookPress: () => void;
|
||||
onApplePress: () => void;
|
||||
}
|
||||
|
||||
export default function SocialButtons({ onGooglePress, onFacebookPress, onApplePress }: SocialButtonsProps) {
|
||||
const colorScheme = useColorScheme();
|
||||
const isDark = colorScheme === 'dark';
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={[styles.divider, { color: isDark ? '#a0aec0' : '#888' }]}>Or continue with</Text>
|
||||
|
||||
<View style={styles.row}>
|
||||
{/* Google Button */}
|
||||
<TouchableOpacity
|
||||
style={[styles.circleButton, { backgroundColor: 'white' }]}
|
||||
onPress={onGooglePress}
|
||||
>
|
||||
<Image
|
||||
source={require('../../assets/icons/google.png')}
|
||||
style={{ width: 24, height: 24 }}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Facebook Button */}
|
||||
<TouchableOpacity
|
||||
style={[styles.circleButton, { backgroundColor: '#1877F2', borderWidth: 0 }]}
|
||||
onPress={onFacebookPress}
|
||||
>
|
||||
<FontAwesome name="facebook" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Apple Button */}
|
||||
{Platform.OS === 'ios' && (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.circleButton,
|
||||
{ backgroundColor: isDark ? 'white' : 'black', borderWidth: 0 }
|
||||
]}
|
||||
onPress={onApplePress}
|
||||
>
|
||||
<FontAwesome name="apple" size={24} color={isDark ? "black" : "white"} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginTop: 30,
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
},
|
||||
divider: {
|
||||
textAlign: 'center',
|
||||
marginBottom: 20,
|
||||
fontSize: 14,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: 20, // Space between buttons
|
||||
},
|
||||
circleButton: {
|
||||
width: 50,
|
||||
height: 50,
|
||||
borderRadius: 25,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'white',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
google: {
|
||||
backgroundColor: 'white',
|
||||
},
|
||||
facebook: {
|
||||
backgroundColor: 'white',
|
||||
},
|
||||
appleLight: {
|
||||
backgroundColor: 'white',
|
||||
},
|
||||
appleDark: {
|
||||
backgroundColor: '#333', // Dark background for Apple button in Dark Mode
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
export const GOOGLE_CONFIG = {
|
||||
iosClientId: '790885459780-8j8tte7sm2vl3kg98aadenvqp9pc5i3g.apps.googleusercontent.com',
|
||||
androidClientId: 'YOUR_ANDROID_CLIENT_ID.apps.googleusercontent.com',
|
||||
webClientId: '790885459780-4c7580aqb5uvtt13ec1386kl6k3fdns4.apps.googleusercontent.com',
|
||||
};
|
||||
|
||||
export const FACEBOOK_CONFIG = {
|
||||
clientId: '1284431066897297',
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Alert } from 'react-native';
|
||||
import * as AppleAuthentication from 'expo-apple-authentication';
|
||||
import * as Google from 'expo-auth-session/providers/google';
|
||||
import * as Facebook from 'expo-auth-session/providers/facebook';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
import { authService } from '../services/api';
|
||||
import { GOOGLE_CONFIG, FACEBOOK_CONFIG } from '../config/social';
|
||||
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
|
||||
export const useAuthHandler = () => {
|
||||
const login = useAuthStore((state) => state.login);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [googleRequest, googleResponse, promptGoogleAsync] = Google.useAuthRequest(GOOGLE_CONFIG);
|
||||
const [fbRequest, fbResponse, promptFacebookAsync] = Facebook.useAuthRequest(FACEBOOK_CONFIG);
|
||||
|
||||
useEffect(() => {
|
||||
if (googleResponse?.type === 'success' && googleResponse.authentication?.accessToken) {
|
||||
handleSocialLogin('google', googleResponse.authentication.accessToken);
|
||||
}
|
||||
}, [googleResponse]);
|
||||
|
||||
useEffect(() => {
|
||||
if (fbResponse?.type === 'success' && fbResponse.authentication?.accessToken) {
|
||||
handleSocialLogin('facebook', fbResponse.authentication.accessToken);
|
||||
}
|
||||
}, [fbResponse]);
|
||||
|
||||
const afterLoginSuccess = async (data: any) => {
|
||||
login(data);
|
||||
const profileData = await authService.getProfile();
|
||||
if (profileData && profileData.data) {
|
||||
useAuthStore.getState().updateUser(profileData.data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = async ({ email, password }) => {
|
||||
if (!email || !password) {
|
||||
Alert.alert('Error', 'Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await authService.login(email, password);
|
||||
if (data.token) {
|
||||
await afterLoginSuccess(data);
|
||||
} else {
|
||||
Alert.alert('Login Failed', data.message || 'Invalid credentials');
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert('Network Error', 'Could not connect to server.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async ({ email, username, password, confirmPassword, tosAccepted }) => {
|
||||
if (!email || !username || !password || !confirmPassword) {
|
||||
return Alert.alert('Error', 'Please fill in all fields');
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
return Alert.alert('Error', 'Passwords do not match');
|
||||
}
|
||||
if (!tosAccepted) {
|
||||
return Alert.alert('Error', 'You must accept the Terms of Service');
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const { ok, data } = await authService.register({ email, username, password, password_confirmation: confirmPassword, tos: true });
|
||||
if (ok) {
|
||||
Alert.alert('Success', 'Account created! Please log in.');
|
||||
return true; // Indicate success to toggle form
|
||||
} else {
|
||||
const errorMessage = data.message || 'Registration failed';
|
||||
const validationErrors = data.errors ? '\n' + Object.values(data.errors).flat().join('\n') : '';
|
||||
Alert.alert('Registration Failed', errorMessage + validationErrors);
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert('Network Error', 'Could not connect to server.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleSocialLogin = async (provider: string, token: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await authService.socialLogin(provider, token);
|
||||
if (data.token) {
|
||||
await afterLoginSuccess(data);
|
||||
} else {
|
||||
Alert.alert('Social Login Failed', data.message || 'Could not verify token');
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert('Error', 'Failed to connect to server');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onAppleButtonPress = async () => {
|
||||
try {
|
||||
const credential = await AppleAuthentication.signInAsync({
|
||||
requestedScopes: [
|
||||
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
||||
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||||
],
|
||||
});
|
||||
if (credential.identityToken) {
|
||||
handleSocialLogin('apple', credential.identityToken);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.code !== 'ERR_CANCELED') {
|
||||
Alert.alert('Error', 'Apple Sign In failed');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
loading,
|
||||
handleLogin,
|
||||
handleRegister,
|
||||
promptGoogle: () => {
|
||||
if (googleRequest) promptGoogleAsync();
|
||||
else Alert.alert('Configuration Error', 'Google Auth Request is not ready.');
|
||||
},
|
||||
promptFacebook: () => {
|
||||
if (fbRequest) promptFacebookAsync();
|
||||
else Alert.alert('Configuration Error', 'Facebook Auth Request is not ready.');
|
||||
},
|
||||
onAppleButtonPress,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Alert } from 'react-native';
|
||||
import { useDocumentStore } from '../store/useDocumentStore';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
|
||||
export const useDocumentActions = () => {
|
||||
const { renameDocument, removeDocument } = useDocumentStore();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const handleRename = (oldName: string) => {
|
||||
Alert.prompt(
|
||||
'Rename Document',
|
||||
'Enter a new name for the document.',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Save',
|
||||
onPress: (newName) => {
|
||||
if (newName && newName !== oldName.replace('.pdf', '') && user?.user.id) {
|
||||
renameDocument(user.user.id, oldName, newName);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
'plain-text',
|
||||
oldName.replace('.pdf', '')
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = (fileName: string) => {
|
||||
Alert.alert(
|
||||
'Delete Document',
|
||||
'Are you sure you want to delete this document?',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
if (user?.user.id) {
|
||||
removeDocument(user.user.id, fileName);
|
||||
}
|
||||
}
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
return { handleRename, handleDelete };
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert } from 'react-native';
|
||||
import DocumentScanner from 'react-native-document-scanner-plugin';
|
||||
import * as Print from 'expo-print';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { useDocumentStore } from '../store/useDocumentStore';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
|
||||
export const useDocumentScanner = () => {
|
||||
const { addDocument } = useDocumentStore();
|
||||
const { user } = useAuthStore();
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
|
||||
const createPdfFromImages = async (imageUris: string[]) => {
|
||||
try {
|
||||
const imageSources = await Promise.all(
|
||||
imageUris.map(async (uri) => {
|
||||
const fileUri = uri.startsWith('file://') ? uri : `file://${uri}`;
|
||||
const base64 = await FileSystem.readAsStringAsync(fileUri, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
return `data:image/jpeg;base64,${base64}`;
|
||||
})
|
||||
);
|
||||
|
||||
const htmlContent = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="margin: 0; padding: 0;">
|
||||
${imageSources.map(src => `
|
||||
<div style="width: 100vw; height: 100vh; display: flex; justify-content: center; align-items: center;">
|
||||
<img src="${src}" style="width: 100%; height: 100%; object-fit: contain;" />
|
||||
</div>
|
||||
`).join('')}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const { uri } = await Print.printToFileAsync({ html: htmlContent });
|
||||
return uri;
|
||||
} catch (error) {
|
||||
console.error('Failed to create PDF', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const scanDocument = async () => {
|
||||
if (!user?.user.id) {
|
||||
Alert.alert('Error', 'You must be logged in to scan documents.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsScanning(true);
|
||||
try {
|
||||
const { scannedImages } = await DocumentScanner.scanDocument();
|
||||
|
||||
if (scannedImages && scannedImages.length > 0) {
|
||||
Alert.prompt(
|
||||
'Name Your Document',
|
||||
'Enter a name for your new document.',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel', onPress: () => {} },
|
||||
{
|
||||
text: 'Save',
|
||||
onPress: async (fileName) => {
|
||||
if (fileName) {
|
||||
try {
|
||||
const pdfUri = await createPdfFromImages(scannedImages);
|
||||
await addDocument(user.user.id, pdfUri, fileName);
|
||||
} catch (e) {
|
||||
Alert.alert('Error', 'Could not save document.');
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
'plain-text',
|
||||
`Scan_${Date.now()}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Scanning failed', error);
|
||||
} finally {
|
||||
setIsScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { isScanning, scanDocument };
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert } from 'react-native';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
import { authService } from '../services/api';
|
||||
|
||||
export const useProfileLogic = () => {
|
||||
const { user, logout } = useAuthStore();
|
||||
const [isResending, setIsResending] = useState(false);
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
|
||||
const displayName =
|
||||
(user?.user?.first_name && user?.user?.last_name) ? `${user.user.first_name} ${user.user.last_name}` :
|
||||
user?.user?.username ? user.user.username :
|
||||
user?.user?.email ? user.user.email.split('@')[0] :
|
||||
'User Name';
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
setIsResending(true);
|
||||
const result = await authService.resendVerificationEmail();
|
||||
setIsResending(false);
|
||||
|
||||
if (result.ok) {
|
||||
Alert.alert('Email Sent', 'Please check your email (or server logs) for the verification link.');
|
||||
} else {
|
||||
Alert.alert('Error', result.message || 'Failed to resend verification email');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAccount = () => {
|
||||
Alert.alert(
|
||||
'Delete Account',
|
||||
'Are you sure you want to permanently delete your account? This action cannot be undone.',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
const result = await authService.deleteAccount();
|
||||
if (result.ok) {
|
||||
logout();
|
||||
Alert.alert('Success', 'Your account has been deleted.');
|
||||
} else {
|
||||
Alert.alert('Error', 'Failed to delete account.');
|
||||
}
|
||||
}
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
user,
|
||||
displayName,
|
||||
isResending,
|
||||
isModalVisible,
|
||||
setIsModalVisible,
|
||||
handleResendVerification,
|
||||
handleDeleteAccount,
|
||||
logout,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
import { authService } from '../services/api';
|
||||
|
||||
export const useProfileManager = () => {
|
||||
const navigation = useNavigation();
|
||||
const { user, updateUser } = useAuthStore();
|
||||
|
||||
const [firstName, setFirstName] = useState(user?.user?.first_name || '');
|
||||
const [lastName, setLastName] = useState(user?.user?.last_name || '');
|
||||
const [phone, setPhone] = useState(user?.user?.phone || '');
|
||||
const [address, setAddress] = useState(user?.user?.address || '');
|
||||
const [avatar, setAvatar] = useState<string | null>(user?.user?.avatar || null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const pickImage = async () => {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: 'images',
|
||||
allowsEditing: true,
|
||||
aspect: [1, 1],
|
||||
quality: 0.5,
|
||||
});
|
||||
|
||||
if (!result.canceled) {
|
||||
setAvatar(result.assets[0].uri);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let updatedAvatarUrl = user?.user?.avatar;
|
||||
|
||||
// Upload avatar if it has changed
|
||||
if (avatar && avatar !== user?.user?.avatar) {
|
||||
const avatarResult = await authService.uploadAvatar(avatar);
|
||||
if (avatarResult.ok && avatarResult.data.data.avatar) {
|
||||
updatedAvatarUrl = avatarResult.data.data.avatar;
|
||||
} else {
|
||||
Alert.alert('Warning', 'Failed to upload new profile picture, but proceeding with other updates.');
|
||||
}
|
||||
}
|
||||
|
||||
const updates = {
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
phone,
|
||||
address,
|
||||
};
|
||||
|
||||
const result = await authService.updateProfile(updates);
|
||||
|
||||
if (result.ok) {
|
||||
const finalUserData = {
|
||||
...(result.data.data || result.data),
|
||||
avatar: updatedAvatarUrl
|
||||
};
|
||||
updateUser(finalUserData);
|
||||
|
||||
Alert.alert('Success', 'Profile updated successfully', [
|
||||
{ text: 'OK', onPress: () => navigation.goBack() }
|
||||
]);
|
||||
} else {
|
||||
Alert.alert('Error', result.message || 'Failed to update profile');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Alert.alert('Error', 'An unexpected error occurred.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
loading,
|
||||
avatar,
|
||||
firstName,
|
||||
lastName,
|
||||
phone,
|
||||
address,
|
||||
setFirstName,
|
||||
setLastName,
|
||||
setPhone,
|
||||
setAddress,
|
||||
pickImage,
|
||||
handleSave,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import { View, ActivityIndicator } from 'react-native';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import AuthScreen from '../screens/AuthScreen';
|
||||
import TabNavigator from './TabNavigator';
|
||||
import EditProfileScreen from '../screens/EditProfileScreen';
|
||||
import DocumentViewScreen from '../screens/DocumentViewScreen';
|
||||
import ForgotPasswordScreen from '../screens/ForgotPasswordScreen';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
|
||||
// Define the types for the stack
|
||||
export type RootStackParamList = {
|
||||
Auth: undefined;
|
||||
Home: undefined; // This is now a nested navigator
|
||||
Main: undefined; // The tab navigator
|
||||
EditProfile: undefined;
|
||||
DocumentView: { uri: string };
|
||||
ForgotPassword: undefined;
|
||||
};
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
|
||||
export default function AppNavigator() {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const isLoading = useAuthStore((state) => state.isLoading);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack.Navigator id="RootStack" screenOptions={{ headerShown: false }}>
|
||||
{user ? (
|
||||
// Screens for logged-in users
|
||||
<>
|
||||
<Stack.Screen name="Main" component={TabNavigator} options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="EditProfile"
|
||||
component={EditProfileScreen}
|
||||
options={{
|
||||
title: 'Edit Profile',
|
||||
headerBackTitle: '',
|
||||
headerShown: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="DocumentView"
|
||||
component={DocumentViewScreen}
|
||||
options={{
|
||||
title: 'Document',
|
||||
headerBackTitle: '',
|
||||
headerShown: true,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
// Screens for logged-out users
|
||||
<>
|
||||
<Stack.Screen name="Auth" component={AuthScreen} />
|
||||
<Stack.Screen
|
||||
name="ForgotPassword"
|
||||
component={ForgotPasswordScreen}
|
||||
options={{ presentation: 'fullScreenModal' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack.Navigator>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { useColorScheme } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import HomeScreen from '../screens/HomeScreen';
|
||||
import ProfileScreen from '../screens/ProfileScreen';
|
||||
import { COLORS } from '../theme/colors';
|
||||
|
||||
const Tab = createBottomTabNavigator();
|
||||
|
||||
export default function TabNavigator() {
|
||||
const colorScheme = useColorScheme();
|
||||
const isDark = colorScheme === 'dark';
|
||||
const themeColors = isDark ? COLORS.DARK : COLORS.LIGHT;
|
||||
|
||||
return (
|
||||
<Tab.Navigator
|
||||
id="MainTabs"
|
||||
screenOptions={({ route }) => ({
|
||||
tabBarIcon: ({ focused, color, size }) => {
|
||||
let iconName;
|
||||
|
||||
if (route.name === 'Home') {
|
||||
iconName = focused ? 'home' : 'home-outline';
|
||||
} else if (route.name === 'Profile') {
|
||||
iconName = focused ? 'person' : 'person-outline';
|
||||
}
|
||||
|
||||
return <Ionicons name={iconName as any} size={20} color={color} />;
|
||||
},
|
||||
tabBarActiveTintColor: themeColors.brand,
|
||||
tabBarInactiveTintColor: isDark ? '#a0aec0' : 'gray',
|
||||
tabBarStyle: {
|
||||
backgroundColor: themeColors.card,
|
||||
borderTopColor: isDark ? '#2d3748' : '#e2e8f0',
|
||||
height: 60
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: themeColors.card,
|
||||
},
|
||||
headerTitleStyle: {
|
||||
color: themeColors.text,
|
||||
fontSize: 17,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Tab.Screen name="Home" component={HomeScreen} />
|
||||
<Tab.Screen
|
||||
name="Profile"
|
||||
component={ProfileScreen}
|
||||
options={{
|
||||
tabBarItemStyle: {
|
||||
borderLeftWidth: 1.5,
|
||||
borderLeftColor: isDark ? '#4A5568' : '#E2E8F0',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { DefaultTheme, DarkTheme } from '@react-navigation/native';
|
||||
import { COLORS } from '../theme/colors';
|
||||
|
||||
export const AppLightTheme = {
|
||||
...DefaultTheme,
|
||||
colors: {
|
||||
...DefaultTheme.colors,
|
||||
background: COLORS.LIGHT.background,
|
||||
card: COLORS.LIGHT.card,
|
||||
text: COLORS.LIGHT.text,
|
||||
primary: COLORS.LIGHT.brand,
|
||||
},
|
||||
};
|
||||
|
||||
export const AppDarkTheme = {
|
||||
...DarkTheme,
|
||||
colors: {
|
||||
...DarkTheme.colors,
|
||||
background: COLORS.DARK.background,
|
||||
card: COLORS.DARK.card,
|
||||
text: COLORS.DARK.text,
|
||||
primary: COLORS.DARK.brand,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { View, StyleSheet, Image, Animated, useColorScheme } from 'react-native';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
|
||||
interface AnimatedSplashProps {
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
export default function AnimatedSplash({ onFinish }: AnimatedSplashProps) {
|
||||
const fadeAnim = useRef(new Animated.Value(1)).current;
|
||||
const [isAppReady, setAppReady] = useState(false);
|
||||
const [isLayoutReady, setLayoutReady] = useState(false);
|
||||
const colorScheme = useColorScheme();
|
||||
const isDark = colorScheme === 'dark';
|
||||
|
||||
useEffect(() => {
|
||||
async function prepare() {
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
} finally {
|
||||
setAppReady(true);
|
||||
}
|
||||
}
|
||||
|
||||
prepare();
|
||||
}, []);
|
||||
|
||||
const onLayoutRootView = useCallback(async () => {
|
||||
setLayoutReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAppReady && isLayoutReady) {
|
||||
// Hide native splash screen
|
||||
SplashScreen.hideAsync();
|
||||
|
||||
// Start fade out
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
onFinish();
|
||||
});
|
||||
}
|
||||
}, [isAppReady, isLayoutReady]);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
opacity: fadeAnim,
|
||||
backgroundColor: isDark ? '#000000' : '#ffffff'
|
||||
}
|
||||
]}
|
||||
onLayout={onLayoutRootView}
|
||||
>
|
||||
<View style={styles.centered}>
|
||||
<Image
|
||||
source={
|
||||
isDark
|
||||
? require('../../assets/icons/splash-icon-light.png')
|
||||
: require('../../assets/icons/splash-icon-dark.png')
|
||||
}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 9999,
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
logo: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, TextInput, TouchableOpacity, ActivityIndicator, Image, Switch, ScrollView, KeyboardAvoidingView, Platform, useColorScheme } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { getThemeStyles, commonStyles } from '../theme/styles';
|
||||
import { COLORS } from '../theme/colors';
|
||||
import SocialButtons from '../components/SocialButtons';
|
||||
import { useAuthHandler } from '../hooks/useAuthHandler';
|
||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
|
||||
type AuthScreenNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Auth'>;
|
||||
|
||||
export default function AuthScreen() {
|
||||
const navigation = useNavigation<AuthScreenNavigationProp>();
|
||||
|
||||
const colorScheme = useColorScheme();
|
||||
const isDark = colorScheme === 'dark';
|
||||
const themeStyles = getThemeStyles(isDark);
|
||||
const brandColor = isDark ? COLORS.DARK.brand : COLORS.LIGHT.brand;
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [tosAccepted, setTosAccepted] = useState(false);
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
|
||||
const {
|
||||
loading,
|
||||
handleLogin,
|
||||
handleRegister,
|
||||
promptGoogle,
|
||||
promptFacebook,
|
||||
onAppleButtonPress,
|
||||
} = useAuthHandler();
|
||||
|
||||
const onRegister = async () => {
|
||||
const success = await handleRegister({ email, username, password, confirmPassword, tosAccepted });
|
||||
if (success) {
|
||||
toggleMode(); // Switch back to login form on success
|
||||
}
|
||||
};
|
||||
|
||||
const onLogin = () => handleLogin({ email, password });
|
||||
|
||||
const toggleMode = () => {
|
||||
setIsRegistering(!isRegistering);
|
||||
setEmail('');
|
||||
setPassword('');
|
||||
setUsername('');
|
||||
setConfirmPassword('');
|
||||
setTosAccepted(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={themeStyles.container}
|
||||
>
|
||||
<ScrollView contentContainerStyle={commonStyles.scrollContent}>
|
||||
<View style={commonStyles.logoContainer}>
|
||||
<Image
|
||||
source={
|
||||
isDark
|
||||
? require('../../assets/icons/ios-dark.png')
|
||||
: require('../../assets/icons/ios-light.png')
|
||||
}
|
||||
style={commonStyles.logo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
<Text style={themeStyles.headerTitle}>CasaDoc</Text>
|
||||
<Text style={[commonStyles.subtitle, themeStyles.subtitle]}>
|
||||
{isRegistering ? 'Create a new account' : 'Sign in to your account'}
|
||||
</Text>
|
||||
|
||||
<View style={[commonStyles.form, themeStyles.card]}>
|
||||
<Text style={[commonStyles.label, themeStyles.subtitle]}>Email{isRegistering ? '' : ' or Username'}</Text>
|
||||
<TextInput
|
||||
style={themeStyles.input}
|
||||
placeholder={isRegistering ? "name@example.com" : "Enter email or username"}
|
||||
placeholderTextColor={isDark ? '#a0aec0' : '#a0aec0'}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType={isRegistering ? "email-address" : "default"}
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
{isRegistering && (
|
||||
<>
|
||||
<Text style={[commonStyles.label, themeStyles.subtitle]}>Username</Text>
|
||||
<TextInput
|
||||
style={themeStyles.input}
|
||||
placeholder="Choose a username"
|
||||
placeholderTextColor={isDark ? '#a0aec0' : '#a0aec0'}
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text style={[commonStyles.label, themeStyles.subtitle]}>Password</Text>
|
||||
<TextInput
|
||||
style={themeStyles.input}
|
||||
placeholder="Enter your password"
|
||||
placeholderTextColor={isDark ? '#a0aec0' : '#a0aec0'}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
|
||||
{isRegistering && (
|
||||
<>
|
||||
<Text style={[commonStyles.label, themeStyles.subtitle]}>Confirm Password</Text>
|
||||
<TextInput
|
||||
style={themeStyles.input}
|
||||
placeholder="Confirm your password"
|
||||
placeholderTextColor={isDark ? '#a0aec0' : '#a0aec0'}
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
|
||||
<View style={commonStyles.tosContainer}>
|
||||
<Switch
|
||||
value={tosAccepted}
|
||||
onValueChange={setTosAccepted}
|
||||
trackColor={{ false: "#767577", true: brandColor }}
|
||||
thumbColor={tosAccepted ? "#fff" : "#f4f3f4"}
|
||||
/>
|
||||
<Text style={[commonStyles.tosText, themeStyles.subtitle]}>I accept the Terms of Service</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[themeStyles.button, loading && commonStyles.buttonDisabled]}
|
||||
onPress={isRegistering ? onRegister : onLogin}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : (
|
||||
<Text style={themeStyles.buttonText}>{isRegistering ? 'Sign Up' : 'Sign In'}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{!isRegistering && (
|
||||
<TouchableOpacity onPress={() => navigation.navigate('ForgotPassword')} style={{ alignSelf: 'flex-end', marginTop: 10 }}>
|
||||
<Text style={themeStyles.linkText}>Forgot Password?</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<SocialButtons
|
||||
onGooglePress={promptGoogle}
|
||||
onFacebookPress={promptFacebook}
|
||||
onApplePress={onAppleButtonPress}
|
||||
/>
|
||||
|
||||
<TouchableOpacity onPress={toggleMode} style={{ marginTop: 20, alignItems: 'center' }}>
|
||||
<Text style={themeStyles.linkText}>
|
||||
{isRegistering
|
||||
? 'Already have an account? Sign In'
|
||||
: 'Don\'t have an account? Sign Up'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, StyleSheet, useColorScheme, Text, ActivityIndicator } from 'react-native';
|
||||
import Pdf from 'react-native-pdf';
|
||||
import { getThemeStyles } from '../theme/styles';
|
||||
|
||||
export default function DocumentViewScreen({ route }: any) {
|
||||
const { uri } = route.params;
|
||||
const colorScheme = useColorScheme();
|
||||
const isDark = colorScheme === 'dark';
|
||||
const themeStyles = getThemeStyles(isDark);
|
||||
|
||||
// Use the URI directly.
|
||||
// Note: If filenames have spaces, they should ideally be encoded,
|
||||
// but for local file:// URIs, react-native-pdf often handles them as they are.
|
||||
// If issues persist with spaces, try encodeURI(uri).
|
||||
const source = React.useMemo(() => ({
|
||||
uri: uri,
|
||||
cache: true
|
||||
}), [uri]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, themeStyles.container]}>
|
||||
<Pdf
|
||||
source={source}
|
||||
onLoadComplete={(numberOfPages, filePath) => {
|
||||
console.log(`Number of pages: ${numberOfPages}`);
|
||||
}}
|
||||
onPageChanged={(page, numberOfPages) => {
|
||||
console.log(`Current page: ${page}`);
|
||||
}}
|
||||
onError={(error) => {
|
||||
console.error('PDF Load Error:', error);
|
||||
}}
|
||||
onPressLink={(uri) => {
|
||||
console.log(`Link pressed: ${uri}`);
|
||||
}}
|
||||
style={styles.pdf}
|
||||
trustAllCerts={false}
|
||||
renderActivityIndicator={() => <ActivityIndicator size="large" color={isDark ? 'white' : 'black'} />}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
},
|
||||
pdf: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TextInput, TouchableOpacity, ScrollView, ActivityIndicator, useColorScheme, KeyboardAvoidingView, Platform, Image } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
import { getThemeStyles, commonStyles } from '../theme/styles';
|
||||
import { COLORS } from '../theme/colors';
|
||||
import { useProfileManager } from '../hooks/useProfileManager';
|
||||
|
||||
export default function EditProfileScreen() {
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const colorScheme = useColorScheme();
|
||||
const isDark = colorScheme === 'dark';
|
||||
const themeStyles = getThemeStyles(isDark);
|
||||
const themeColors = isDark ? COLORS.DARK : COLORS.LIGHT;
|
||||
|
||||
const {
|
||||
loading,
|
||||
avatar,
|
||||
firstName,
|
||||
lastName,
|
||||
phone,
|
||||
address,
|
||||
setFirstName,
|
||||
setLastName,
|
||||
setPhone,
|
||||
setAddress,
|
||||
pickImage,
|
||||
handleSave,
|
||||
} = useProfileManager();
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={themeStyles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 100 : 0}
|
||||
>
|
||||
<ScrollView contentContainerStyle={{ padding: 20 }}>
|
||||
<Text style={[commonStyles.title, themeStyles.text, { textAlign: 'left', marginBottom: 20 }]}>
|
||||
Edit Profile
|
||||
</Text>
|
||||
|
||||
<View style={{ alignItems: 'center', marginBottom: 30 }}>
|
||||
<TouchableOpacity onPress={pickImage} style={{ position: 'relative' }}>
|
||||
<View style={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: themeColors.brand,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{avatar ? (
|
||||
<Image source={{ uri: avatar }} style={{ width: '100%', height: '100%' }} />
|
||||
) : (
|
||||
<Text style={{ fontSize: 40, fontWeight: 'bold', color: 'white' }}>
|
||||
{user?.user?.first_name?.charAt(0).toUpperCase() || user?.user?.email?.charAt(0).toUpperCase() || 'U'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
backgroundColor: themeColors.card,
|
||||
borderRadius: 15,
|
||||
padding: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: isDark ? '#2d3748' : '#e2e8f0',
|
||||
}}>
|
||||
<Ionicons name="camera" size={18} color={themeColors.text} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={commonStyles.form}>
|
||||
<Text style={[commonStyles.label, themeStyles.subtitle]}>First Name</Text>
|
||||
<TextInput
|
||||
style={themeStyles.input}
|
||||
value={firstName}
|
||||
onChangeText={setFirstName}
|
||||
placeholder="Enter first name"
|
||||
placeholderTextColor={isDark ? '#a0aec0' : '#a0aec0'}
|
||||
/>
|
||||
|
||||
<Text style={[commonStyles.label, themeStyles.subtitle]}>Last Name</Text>
|
||||
<TextInput
|
||||
style={themeStyles.input}
|
||||
value={lastName}
|
||||
onChangeText={setLastName}
|
||||
placeholder="Enter last name"
|
||||
placeholderTextColor={isDark ? '#a0aec0' : '#a0aec0'}
|
||||
/>
|
||||
|
||||
<Text style={[commonStyles.label, themeStyles.subtitle]}>Phone</Text>
|
||||
<TextInput
|
||||
style={themeStyles.input}
|
||||
value={phone}
|
||||
onChangeText={setPhone}
|
||||
placeholder="Enter phone number"
|
||||
keyboardType="phone-pad"
|
||||
placeholderTextColor={isDark ? '#a0aec0' : '#a0aec0'}
|
||||
/>
|
||||
|
||||
<Text style={[commonStyles.label, themeStyles.subtitle]}>Address</Text>
|
||||
<TextInput
|
||||
style={[themeStyles.input, { height: 100, textAlignVertical: 'top' }]}
|
||||
value={address}
|
||||
onChangeText={setAddress}
|
||||
placeholder="Enter address"
|
||||
multiline
|
||||
placeholderTextColor={isDark ? '#a0aec0' : '#a0aec0'}
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[themeStyles.button, loading && commonStyles.buttonDisabled, { marginTop: 40 }]}
|
||||
onPress={handleSave}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : (
|
||||
<Text style={themeStyles.buttonText}>Save Changes</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, TextInput, TouchableOpacity, ActivityIndicator, Image, KeyboardAvoidingView, Platform, Alert, useColorScheme, ScrollView } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { authService } from '../services/api';
|
||||
import { getThemeStyles, commonStyles } from '../theme/styles';
|
||||
|
||||
export default function ForgotPasswordScreen() {
|
||||
const navigation = useNavigation();
|
||||
const colorScheme = useColorScheme();
|
||||
const isDark = colorScheme === 'dark';
|
||||
const themeStyles = getThemeStyles(isDark);
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleReset = async () => {
|
||||
if (!email) {
|
||||
Alert.alert('Error', 'Please enter your email');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const { ok, message } = await authService.sendPasswordResetEmail(email);
|
||||
|
||||
if (ok) {
|
||||
Alert.alert(
|
||||
'Check your email',
|
||||
'We have sent you a password reset link.',
|
||||
[{ text: 'OK', onPress: () => navigation.goBack() }]
|
||||
);
|
||||
} else {
|
||||
Alert.alert('Error', message || 'Could not send reset link');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Alert.alert('Network Error', 'Could not connect to server.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={themeStyles.container}
|
||||
>
|
||||
<ScrollView contentContainerStyle={commonStyles.scrollContent}>
|
||||
<View style={commonStyles.logoContainer}>
|
||||
<Image
|
||||
source={isDark ? require('../../assets/icons/ios-dark.png') : require('../../assets/icons/ios-light.png')}
|
||||
style={commonStyles.logo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
<Text style={themeStyles.headerTitle}>CasaDoc</Text>
|
||||
<Text style={[commonStyles.subtitle, themeStyles.subtitle]}>
|
||||
Reset Password
|
||||
</Text>
|
||||
|
||||
<View style={[commonStyles.form, themeStyles.card]}>
|
||||
<Text style={[commonStyles.label, themeStyles.subtitle]}>Email Address</Text>
|
||||
<TextInput
|
||||
style={[themeStyles.input]}
|
||||
placeholder="name@example.com"
|
||||
placeholderTextColor={isDark ? '#a0aec0' : '#a0aec0'}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[themeStyles.button, loading && commonStyles.buttonDisabled]}
|
||||
onPress={handleReset}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : (
|
||||
<Text style={themeStyles.buttonText}>Send Reset Link</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity onPress={() => navigation.goBack()} style={{ marginTop: 20, alignItems: 'center' }}>
|
||||
<Text style={themeStyles.linkText}>
|
||||
Back to Login
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, useColorScheme, FlatList, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
import { getThemeStyles, commonStyles, homeStyles } from '../theme/styles';
|
||||
import { useDocumentStore } from '../store/useDocumentStore';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
import { useDocumentScanner } from '../hooks/useDocumentScanner';
|
||||
import { useDocumentActions } from '../hooks/useDocumentActions';
|
||||
import { COLORS } from '../theme/colors';
|
||||
|
||||
export default function HomeScreen() {
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const colorScheme = useColorScheme();
|
||||
const isDark = colorScheme === 'dark';
|
||||
const themeStyles = getThemeStyles(isDark);
|
||||
const themeColors = isDark ? COLORS.DARK : COLORS.LIGHT;
|
||||
|
||||
const { user } = useAuthStore();
|
||||
const { documents, loadDocuments, isLoading } = useDocumentStore();
|
||||
const { isScanning, scanDocument } = useDocumentScanner();
|
||||
const { handleRename, handleDelete } = useDocumentActions();
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.user?.id) {
|
||||
loadDocuments(user.user.id);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const renderItem = ({ item }: { item: any }) => (
|
||||
<TouchableOpacity onPress={() => navigation.navigate('DocumentView', { uri: item.uri })} activeOpacity={0.7}>
|
||||
<View style={[homeStyles.docCard, { backgroundColor: themeColors.card, borderColor: isDark ? '#2d3748' : '#e2e8f0' }]}>
|
||||
<View style={homeStyles.docIcon}>
|
||||
<Ionicons name="document-text-outline" size={32} color={themeColors.brand} />
|
||||
</View>
|
||||
<View style={homeStyles.docInfo}>
|
||||
<Text style={[homeStyles.docName, { color: themeColors.text }]} numberOfLines={1}>
|
||||
{item.name.replace('.pdf', '')}
|
||||
</Text>
|
||||
<Text style={[homeStyles.docDate, { color: isDark ? '#a0aec0' : '#718096' }]}>
|
||||
{new Date(item.timestamp).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity onPress={() => handleRename(item.name)} style={homeStyles.deleteBtn}>
|
||||
<Ionicons name="create-outline" size={22} color={isDark ? '#a0aec0' : '#718096'} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => handleDelete(item.name)} style={homeStyles.deleteBtn}>
|
||||
<Ionicons name="trash-outline" size={22} color="#ef4444" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={themeStyles.container}>
|
||||
{documents.length === 0 ? (
|
||||
<View style={commonStyles.centered}>
|
||||
<Ionicons name="documents-outline" size={64} color={isDark ? '#4a5568' : '#cbd5e0'} />
|
||||
<Text style={[themeStyles.subtitle, { marginTop: 16 }]}>No documents yet.</Text>
|
||||
<Text style={[themeStyles.text, { textAlign: 'center', opacity: 0.6 }]}>
|
||||
Tap the + button to scan your first document.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={documents}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.name}
|
||||
contentContainerStyle={homeStyles.listContent}
|
||||
refreshing={isLoading}
|
||||
onRefresh={() => user?.user.id && loadDocuments(user.user.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isScanning && (
|
||||
<View style={homeStyles.loadingOverlay}>
|
||||
<ActivityIndicator size="large" color="#fff" />
|
||||
<Text style={{ color: '#fff', marginTop: 10 }}>Processing...</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[homeStyles.fab, { backgroundColor: themeColors.brand }]}
|
||||
onPress={scanDocument}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="add" size={32} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, useColorScheme, ScrollView, Image, ActivityIndicator, Modal, Alert } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
import { getThemeStyles } from '../theme/styles';
|
||||
import { COLORS } from '../theme/colors';
|
||||
import { useProfileLogic } from '../hooks/useProfileLogic';
|
||||
import appConfig from '../../app.json';
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const colorScheme = useColorScheme();
|
||||
const isDark = colorScheme === 'dark';
|
||||
const themeStyles = getThemeStyles(isDark);
|
||||
const themeColors = isDark ? COLORS.DARK : COLORS.LIGHT;
|
||||
|
||||
const {
|
||||
user,
|
||||
displayName,
|
||||
isResending,
|
||||
isModalVisible,
|
||||
setIsModalVisible,
|
||||
handleResendVerification,
|
||||
handleDeleteAccount,
|
||||
logout,
|
||||
} = useProfileLogic();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const MenuItem = ({ icon, label, onPress, isDestructive = false }: { icon: string, label: string, onPress: () => void, isDestructive?: boolean }) => (
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: isDark ? '#2d3748' : '#edf2f7',
|
||||
}}
|
||||
onPress={onPress}
|
||||
>
|
||||
<View style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: isDestructive ? '#fff5f5' : (isDark ? '#2d3748' : '#ebf8ff'),
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 16
|
||||
}}>
|
||||
<Ionicons
|
||||
name={icon as any}
|
||||
size={20}
|
||||
color={isDestructive ? '#e53e3e' : themeColors.brand}
|
||||
/>
|
||||
</View>
|
||||
<Text style={{
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
color: isDestructive ? '#e53e3e' : themeColors.text,
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{label}
|
||||
</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color={isDark ? '#718096' : '#cbd5e0'} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={themeStyles.container}
|
||||
contentContainerStyle={{ paddingBottom: 40 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{!user.user?.email_verified_at && (
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
backgroundColor: '#fffaf0',
|
||||
padding: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#feebc8'
|
||||
}}
|
||||
onPress={handleResendVerification}
|
||||
disabled={isResending}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', flex: 1 }}>
|
||||
<Ionicons name="warning-outline" size={20} color="#ed8936" />
|
||||
<Text style={{ marginLeft: 10, color: '#c05621', fontSize: 14, flex: 1 }}>
|
||||
Your email is not verified. Tap to resend link.
|
||||
</Text>
|
||||
</View>
|
||||
{isResending ? (
|
||||
<ActivityIndicator size="small" color="#c05621" />
|
||||
) : (
|
||||
<Ionicons name="arrow-forward" size={18} color="#c05621" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Header Profile Section */}
|
||||
<View style={{ alignItems: 'center', paddingTop: 40, paddingBottom: 30 }}>
|
||||
<View style={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: 'white',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 16,
|
||||
shadowColor: themeColors.brand,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 6,
|
||||
overflow: 'hidden', // Ensure image clips to circle
|
||||
}}>
|
||||
{user.user?.avatar ? (
|
||||
<TouchableOpacity onPress={() => setIsModalVisible(true)} style={{ width: '100%', height: '100%' }}>
|
||||
<Image
|
||||
source={{ uri: user.user.avatar }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<Text style={{ fontSize: 40, fontWeight: 'bold', color: 'white' }}>
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text style={{ fontSize: 24, fontWeight: 'bold', color: themeColors.text, marginBottom: 4 }}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 16, color: themeColors.subtitle }}>
|
||||
{user.user?.email}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Settings Section */}
|
||||
<View style={{
|
||||
backgroundColor: themeColors.card,
|
||||
marginHorizontal: 20,
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 20,
|
||||
marginBottom: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2
|
||||
}}>
|
||||
<MenuItem icon="person-outline" label="Edit Profile" onPress={() => navigation.navigate('EditProfile')} />
|
||||
<MenuItem icon="notifications-outline" label="Notifications" onPress={() => {}} />
|
||||
<MenuItem icon="key-outline" label="API Token" onPress={() => Alert.alert('Your Token', user.token)} />
|
||||
<MenuItem icon="help-circle-outline" label="Help & Support" onPress={() => {}} />
|
||||
</View>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<View style={{
|
||||
backgroundColor: themeColors.card,
|
||||
marginHorizontal: 20,
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2
|
||||
}}>
|
||||
<MenuItem icon="log-out-outline" label="Sign Out" onPress={logout} />
|
||||
<MenuItem icon="trash-outline" label="Delete Account" onPress={handleDeleteAccount} isDestructive />
|
||||
</View>
|
||||
|
||||
<Text style={{
|
||||
textAlign: 'center',
|
||||
marginTop: 30,
|
||||
color: themeColors.subtitle,
|
||||
fontSize: 12
|
||||
}}>
|
||||
Version {appConfig.expo.version}
|
||||
</Text>
|
||||
|
||||
<Modal
|
||||
visible={isModalVisible}
|
||||
transparent={true}
|
||||
animationType="fade"
|
||||
onRequestClose={() => setIsModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.90)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
{user.user?.avatar && (
|
||||
<View style={{
|
||||
width: 300,
|
||||
height: 300,
|
||||
borderRadius: 150, // Circular container
|
||||
backgroundColor: 'black',
|
||||
overflow: 'hidden',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 0.5,
|
||||
borderColor: 'white'
|
||||
}}>
|
||||
<Image
|
||||
source={{ uri: user.user.avatar }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={{ position: 'absolute', top: 50, right: 20, padding: 10 }}
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<Ionicons name="close" size={30} color="white" />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
|
||||
const getDocumentDir = (userId: string | number) => `${FileSystem.documentDirectory}documents/user_${userId}/`;
|
||||
|
||||
export interface ScannedDocument {
|
||||
uri: string;
|
||||
name: string;
|
||||
timestamp: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const DocumentService = {
|
||||
// Ensure the documents directory exists for the specific user
|
||||
init: async (userId: string | number) => {
|
||||
const dir = getDocumentDir(userId);
|
||||
const dirInfo = await FileSystem.getInfoAsync(dir);
|
||||
if (!dirInfo.exists) {
|
||||
await FileSystem.makeDirectoryAsync(dir, { intermediates: true });
|
||||
}
|
||||
},
|
||||
|
||||
// Save a scanned file from a temporary URI to permanent storage
|
||||
saveDocument: async (userId: string | number, tempUri: string, fileName: string): Promise<ScannedDocument> => {
|
||||
await DocumentService.init(userId);
|
||||
|
||||
const dir = getDocumentDir(userId);
|
||||
const timestamp = Date.now();
|
||||
const destination = `${dir}${fileName}.pdf`;
|
||||
|
||||
await FileSystem.moveAsync({
|
||||
from: tempUri,
|
||||
to: destination,
|
||||
});
|
||||
|
||||
const fileInfo = await FileSystem.getInfoAsync(destination);
|
||||
|
||||
return {
|
||||
uri: destination,
|
||||
name: `${fileName}.pdf`,
|
||||
timestamp,
|
||||
size: fileInfo.exists ? fileInfo.size : 0,
|
||||
};
|
||||
},
|
||||
|
||||
// Get a list of all saved documents for a user
|
||||
getDocuments: async (userId: string | number): Promise<ScannedDocument[]> => {
|
||||
await DocumentService.init(userId);
|
||||
|
||||
const dir = getDocumentDir(userId);
|
||||
const files = await FileSystem.readDirectoryAsync(dir);
|
||||
|
||||
const docs: ScannedDocument[] = await Promise.all(
|
||||
files.map(async (fileName) => {
|
||||
const uri = `${dir}${fileName}`;
|
||||
const info = await FileSystem.getInfoAsync(uri);
|
||||
// Extract timestamp from filename if possible, else use modification time
|
||||
const match = fileName.match(/scan_(\d+)/);
|
||||
const timestamp = match ? parseInt(match[1]) : (info.exists ? info.modificationTime || 0 : 0) * 1000;
|
||||
|
||||
return {
|
||||
uri,
|
||||
name: fileName,
|
||||
timestamp,
|
||||
size: info.exists ? info.size : 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Sort by newest first
|
||||
return docs.sort((a, b) => b.timestamp - a.timestamp);
|
||||
},
|
||||
|
||||
// Delete a document
|
||||
deleteDocument: async (userId: string | number, fileName: string) => {
|
||||
const dir = getDocumentDir(userId);
|
||||
const uri = `${dir}${fileName}`;
|
||||
await FileSystem.deleteAsync(uri, { idempotent: true });
|
||||
},
|
||||
|
||||
// Rename a document
|
||||
renameDocument: async (userId: string | number, oldName: string, newName: string): Promise<ScannedDocument> => {
|
||||
const dir = getDocumentDir(userId);
|
||||
const oldUri = `${dir}${oldName}`;
|
||||
const newUri = `${dir}${newName}.pdf`; // Assuming pdf extension
|
||||
|
||||
await FileSystem.moveAsync({
|
||||
from: oldUri,
|
||||
to: newUri,
|
||||
});
|
||||
|
||||
const fileInfo = await FileSystem.getInfoAsync(newUri);
|
||||
const timestamp = fileInfo.exists ? (fileInfo.modificationTime || 0) * 1000 : Date.now();
|
||||
|
||||
return {
|
||||
uri: newUri,
|
||||
name: `${newName}.pdf`,
|
||||
timestamp,
|
||||
size: fileInfo.exists ? fileInfo.size : 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
|
||||
const getAuthHeaders = () => {
|
||||
const token = useAuthStore.getState().user?.token;
|
||||
return {
|
||||
'Accept': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export const API_URL = 'https://supercuriously-precongested-chester.ngrok-free.dev/api'; // ngrok URL for testing
|
||||
|
||||
export const authService = {
|
||||
login: async (username, password) => {
|
||||
console.log('Attempting login to:', `${API_URL}/login`);
|
||||
const response = await fetch(`${API_URL}/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username, // Vanguard expects 'username' key even for emails
|
||||
password,
|
||||
device_name: 'react-native-app',
|
||||
}),
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
|
||||
register: async (data) => {
|
||||
const response = await fetch(`${API_URL}/register`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...data,
|
||||
device_name: 'react-native-app',
|
||||
}),
|
||||
});
|
||||
return {
|
||||
ok: response.ok,
|
||||
data: await response.json(),
|
||||
};
|
||||
},
|
||||
|
||||
sendPasswordResetEmail: async (email) => {
|
||||
const response = await fetch(`${API_URL}/password/remind`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// Parse error message
|
||||
const data = await response.json();
|
||||
return { ok: false, message: data.message || 'Failed to send reset link' };
|
||||
},
|
||||
|
||||
socialLogin: async (provider, token) => {
|
||||
console.log(`Attempting ${provider} login`);
|
||||
const response = await fetch(`${API_URL}/login/social`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
network: provider,
|
||||
social_token: token,
|
||||
device_name: 'react-native-app',
|
||||
}),
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
|
||||
getProfile: async () => {
|
||||
const response = await fetch(`${API_URL}/me`, {
|
||||
method: 'GET',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
|
||||
updateProfile: async (data: any) => {
|
||||
const response = await fetch(`${API_URL}/me/details`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
if (response.ok) {
|
||||
return { ok: true, data: json };
|
||||
}
|
||||
return { ok: false, message: json.message || 'Failed to update profile' };
|
||||
} catch (e) {
|
||||
console.error('Update Profile Error (Raw Response):', text);
|
||||
return { ok: false, message: 'Server returned an invalid response' };
|
||||
}
|
||||
},
|
||||
|
||||
uploadAvatar: async (imageUri: string) => {
|
||||
const formData = new FormData();
|
||||
|
||||
// Simplification: Always name the file 'avatar.jpg' and type 'image/jpeg'.
|
||||
// This avoids issues with weird temp filenames or missing extensions
|
||||
// that cause backend validation ("must be an image") to fail.
|
||||
// React Native's FormData will read the file bytes correctly from the URI.
|
||||
const filename = 'avatar.jpg';
|
||||
const type = 'image/jpeg';
|
||||
|
||||
// React Native FormData expects an object with uri, name, type.
|
||||
// Cast to 'any' to avoid TypeScript type mismatch.
|
||||
formData.append('file', { uri: imageUri, name: filename, type } as any);
|
||||
|
||||
const response = await fetch(`${API_URL}/me/avatar`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
if (response.ok) {
|
||||
return { ok: true, data: json };
|
||||
}
|
||||
console.error('Upload Avatar Failed (Server Message):', json);
|
||||
return { ok: false, message: json.message || 'Failed to upload avatar' };
|
||||
} catch (e) {
|
||||
console.error('Upload Avatar Error (Raw Response):', text);
|
||||
return { ok: false, message: 'Server returned an invalid response' };
|
||||
}
|
||||
},
|
||||
|
||||
resendVerificationEmail: async () => {
|
||||
const response = await fetch(`${API_URL}/email/resend`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
return { ok: false, message: json.message || 'Failed to send verification email' };
|
||||
} catch {
|
||||
return { ok: false, message: 'Network error' };
|
||||
}
|
||||
},
|
||||
|
||||
deleteAccount: async () => {
|
||||
const response = await fetch(`${API_URL}/me`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
console.error('Delete Account Failed (Server Message):', json);
|
||||
return { ok: false, message: json.message || 'Failed to delete account' };
|
||||
} catch {
|
||||
console.error('Delete Account Failed (Raw Response):', text);
|
||||
return { ok: false, message: 'Server returned an invalid response' };
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
interface User {
|
||||
token: string;
|
||||
user: {
|
||||
id: number;
|
||||
email: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
username?: string;
|
||||
email_verified_at?: string | null;
|
||||
avatar?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
login: (userData: User) => void;
|
||||
updateUser: (updates: Partial<User['user']>) => void;
|
||||
logout: () => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
user: null,
|
||||
isLoading: true,
|
||||
login: (userData) => set({ user: userData }),
|
||||
updateUser: (updates) =>
|
||||
set((state) => ({
|
||||
user: state.user
|
||||
? { ...state.user, user: { ...state.user.user, ...updates } }
|
||||
: null,
|
||||
})),
|
||||
logout: () => set({ user: null }),
|
||||
setLoading: (loading) => set({ isLoading: loading }),
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
state?.setLoading(false);
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,65 @@
|
||||
import { create } from 'zustand';
|
||||
import { DocumentService, ScannedDocument } from '../services/DocumentService';
|
||||
|
||||
interface DocumentState {
|
||||
documents: ScannedDocument[];
|
||||
isLoading: boolean;
|
||||
loadDocuments: (userId: string | number) => Promise<void>;
|
||||
addDocument: (userId: string | number, tempUri: string, fileName: string) => Promise<void>;
|
||||
removeDocument: (userId: string | number, fileName: string) => Promise<void>;
|
||||
renameDocument: (userId: string | number, oldName: string, newName: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useDocumentStore = create<DocumentState>((set, get) => ({
|
||||
documents: [],
|
||||
isLoading: false,
|
||||
|
||||
loadDocuments: async (userId: string | number) => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const docs = await DocumentService.getDocuments(userId);
|
||||
set({ documents: docs });
|
||||
} catch (error) {
|
||||
console.error('Failed to load documents', error);
|
||||
set({ documents: [] });
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
addDocument: async (userId: string | number, tempUri: string, fileName: string) => {
|
||||
try {
|
||||
const newDoc = await DocumentService.saveDocument(userId, tempUri, fileName);
|
||||
set((state) => ({
|
||||
documents: [newDoc, ...state.documents].sort((a, b) => b.timestamp - a.timestamp),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to save document', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
removeDocument: async (userId: string | number, fileName: string) => {
|
||||
try {
|
||||
await DocumentService.deleteDocument(userId, fileName);
|
||||
set((state) => ({
|
||||
documents: state.documents.filter((doc) => doc.name !== fileName),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete document', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
renameDocument: async (userId: string | number, oldName: string, newName: string) => {
|
||||
try {
|
||||
const updatedDoc = await DocumentService.renameDocument(userId, oldName, newName);
|
||||
set((state) => ({
|
||||
documents: state.documents.map((doc) => (doc.name === oldName ? updatedDoc : doc)),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to rename document', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,24 @@
|
||||
export const COLORS = {
|
||||
LIGHT: {
|
||||
brand: '#23568C', // Dark Blue
|
||||
background: '#f5f5f5',
|
||||
card: '#ffffff',
|
||||
text: '#333333',
|
||||
subtitle: '#666666',
|
||||
inputBackground: '#f7fafc',
|
||||
inputBorder: '#e2e8f0',
|
||||
inputText: '#2d3748',
|
||||
tokenBox: '#f7fafc',
|
||||
},
|
||||
DARK: {
|
||||
brand: '#CBEFFF', // Light Blue
|
||||
background: '#1a202c',
|
||||
card: '#2d3748',
|
||||
text: '#ffffff',
|
||||
subtitle: '#a0aec0',
|
||||
inputBackground: '#4a5568',
|
||||
inputBorder: '#718096',
|
||||
inputText: '#ffffff',
|
||||
tokenBox: '#1a202c',
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { COLORS } from './colors';
|
||||
|
||||
export const getThemeStyles = (isDark: boolean) => {
|
||||
const theme = isDark ? COLORS.DARK : COLORS.LIGHT;
|
||||
|
||||
return StyleSheet.create({
|
||||
container: { backgroundColor: theme.background, flex: 1 },
|
||||
text: { color: theme.text },
|
||||
card: { backgroundColor: theme.card },
|
||||
input: {
|
||||
backgroundColor: theme.inputBackground,
|
||||
color: theme.inputText,
|
||||
borderColor: theme.inputBorder,
|
||||
borderWidth: 1,
|
||||
padding: 15,
|
||||
borderRadius: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
subtitle: { color: theme.subtitle },
|
||||
tokenBox: { backgroundColor: theme.tokenBox },
|
||||
|
||||
// Dynamic brand color styles
|
||||
headerTitle: {
|
||||
color: theme.brand,
|
||||
fontSize: 36,
|
||||
fontWeight: '800',
|
||||
textAlign: 'center',
|
||||
marginBottom: 5,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: theme.brand,
|
||||
padding: 18,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginTop: 35,
|
||||
marginBottom: 10,
|
||||
shadowColor: theme.brand,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
avatar: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: theme.brand,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 15,
|
||||
},
|
||||
linkText: {
|
||||
color: theme.brand,
|
||||
fontWeight: '600',
|
||||
},
|
||||
buttonText: {
|
||||
color: isDark ? COLORS.LIGHT.brand : '#ffffff',
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const commonStyles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 25,
|
||||
paddingTop: 60,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 25,
|
||||
},
|
||||
logoContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
logo: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
marginBottom: 40,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 30,
|
||||
textAlign: 'center',
|
||||
},
|
||||
form: {
|
||||
padding: 25,
|
||||
borderRadius: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 10,
|
||||
elevation: 5,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
marginBottom: 8,
|
||||
marginTop: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
card: {
|
||||
padding: 25,
|
||||
borderRadius: 20,
|
||||
marginBottom: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
userInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
avatarText: {
|
||||
color: 'white',
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
value: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
tokenBox: {
|
||||
padding: 15,
|
||||
borderRadius: 10,
|
||||
marginTop: 10,
|
||||
},
|
||||
token: {
|
||||
fontSize: 12,
|
||||
color: '#718096',
|
||||
fontFamily: 'Courier',
|
||||
marginTop: 5,
|
||||
},
|
||||
logoutButton: {
|
||||
backgroundColor: 'white',
|
||||
padding: 15,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: '#e2e8f0',
|
||||
},
|
||||
logoutText: {
|
||||
color: '#e53e3e',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
tosContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 15,
|
||||
},
|
||||
tosText: {
|
||||
marginLeft: 10,
|
||||
fontSize: 14,
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
});
|
||||
|
||||
export const homeStyles = StyleSheet.create({
|
||||
listContent: {
|
||||
padding: 16,
|
||||
paddingBottom: 100, // Space for FAB
|
||||
},
|
||||
docCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
borderWidth: 1,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
docIcon: {
|
||||
marginRight: 16,
|
||||
},
|
||||
docInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
docName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
docDate: {
|
||||
fontSize: 12,
|
||||
},
|
||||
deleteBtn: {
|
||||
padding: 8,
|
||||
},
|
||||
fab: {
|
||||
position: 'absolute',
|
||||
bottom: 24,
|
||||
right: 24,
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 4.65,
|
||||
elevation: 8,
|
||||
},
|
||||
loadingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base"
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
// add-document.php
|
||||
include('include/headscript.php');
|
||||
|
||||
// Connessione al database
|
||||
$conn = new mysqli($servername, $username, $password, $database);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
die("Errore di connessione: " . $conn->connect_error);
|
||||
}
|
||||
|
||||
if ($_SERVER["REQUEST_METHOD"] == "POST") {
|
||||
$document_name = $conn->real_escape_string($_POST['document_name']);
|
||||
$page_id = isset($_POST['page_id']) ? (int)$_POST['page_id'] : null;
|
||||
$idsections = isset($_POST['idsections']) ? (int)$_POST['idsections'] : null;
|
||||
$max_documents = isset($_POST['max_documents']) ? (int)$_POST['max_documents'] : 0;
|
||||
$is_required = isset($_POST['is_required']) ? (int)$_POST['is_required'] : 0;
|
||||
$notes = !empty($_POST['notes']) ? $conn->real_escape_string($_POST['notes']) : null;
|
||||
$tags = isset($_POST['tags']) ? $_POST['tags'] : []; // Array di tag selezionati
|
||||
|
||||
// Inserisci il documento
|
||||
$stmt = $conn->prepare("INSERT INTO documents (document_name, page_id, idsections, max_documents, is_required, notes) VALUES (?, ?, ?, ?, ?, ?)");
|
||||
$stmt->bind_param("siiiss", $document_name, $page_id, $idsections, $max_documents, $is_required, $notes);
|
||||
$stmt->execute();
|
||||
$document_id = $conn->insert_id;
|
||||
$stmt->close();
|
||||
|
||||
// Inserisci i tag nella tabella document_tags
|
||||
if (!empty($tags)) {
|
||||
$stmt = $conn->prepare("INSERT INTO document_tags (document_id, tag_id) VALUES (?, ?)");
|
||||
foreach ($tags as $tag_id) {
|
||||
$tag_id = (int)$tag_id;
|
||||
$stmt->bind_param("ii", $document_id, $tag_id);
|
||||
$stmt->execute();
|
||||
}
|
||||
$stmt->close();
|
||||
}
|
||||
|
||||
header("Location: documents-settings.php?success=1");
|
||||
exit();
|
||||
} else {
|
||||
header("Location: documents-settings.php?error=1");
|
||||
exit();
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
@@ -1,78 +0,0 @@
|
||||
<?php
|
||||
ini_set('display_errors', 1);
|
||||
ini_set('display_startup_errors', 1);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once __DIR__ . '/class/db-functions.php';
|
||||
$dbHandler = DBHandlerSelect::getInstance();
|
||||
$pdo = $dbHandler->getConnection();
|
||||
|
||||
// Ricezione dati dal form
|
||||
$user_id = isset($_POST['user_id']) ? (int)$_POST['user_id'] : 0;
|
||||
$first_name = isset($_POST['first_name']) ? htmlspecialchars($_POST['first_name']) : null;
|
||||
$last_name = isset($_POST['last_name']) ? htmlspecialchars($_POST['last_name']) : null;
|
||||
$company_name = isset($_POST['company_name']) ? htmlspecialchars($_POST['company_name']) : null;
|
||||
$tax_code = isset($_POST['tax_code']) ? htmlspecialchars($_POST['tax_code']) : null;
|
||||
$email = isset($_POST['email']) ? htmlspecialchars($_POST['email']) : null;
|
||||
$phone = isset($_POST['phone']) ? htmlspecialchars($_POST['phone']) : null;
|
||||
$address = isset($_POST['address']) ? htmlspecialchars($_POST['address']) : null;
|
||||
$postal_code = isset($_POST['postal_code']) ? htmlspecialchars($_POST['postal_code']) : null;
|
||||
$city = isset($_POST['city']) ? htmlspecialchars($_POST['city']) : null;
|
||||
$province = isset($_POST['province']) ? htmlspecialchars($_POST['province']) : null;
|
||||
$country = isset($_POST['country']) ? (int)$_POST['country'] : null;
|
||||
$owner_type = isset($_POST['owner_type']) ? htmlspecialchars($_POST['owner_type']) : null;
|
||||
$role = isset($_POST['role']) ? htmlspecialchars($_POST['role']) : null;
|
||||
$notes = isset($_POST['notes']) ? htmlspecialchars($_POST['notes']) : null;
|
||||
|
||||
// Validazione dei campi obbligatori
|
||||
if (!$user_id || !$tax_code || !$email || !$owner_type) {
|
||||
die(json_encode(["success" => false, "message" => "Campi obbligatori mancanti."]));
|
||||
}
|
||||
|
||||
// Se è una persona fisica, company_name deve essere NULL
|
||||
if ($owner_type === "individual") {
|
||||
$company_name = null;
|
||||
}
|
||||
|
||||
// Controlla se il proprietario esiste già (evita duplicati)
|
||||
$stmt = $pdo->prepare("SELECT owner_id FROM property_owners WHERE tax_code = ?");
|
||||
$stmt->execute([$tax_code]);
|
||||
if ($stmt->fetch()) {
|
||||
die(json_encode(["success" => false, "message" => "Esiste già un proprietario con questo Codice Fiscale/Partita IVA."]));
|
||||
}
|
||||
|
||||
// Inserisci nuovo proprietario
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO property_owners (user_id, first_name, last_name, company_name, tax_code, email, phone, address, postal_code, city, province, country, owner_type, role, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
try {
|
||||
$stmt->execute([
|
||||
$user_id,
|
||||
$first_name,
|
||||
$last_name,
|
||||
$company_name,
|
||||
$tax_code,
|
||||
$email,
|
||||
$phone,
|
||||
$address,
|
||||
$postal_code,
|
||||
$city,
|
||||
$province,
|
||||
$country,
|
||||
$owner_type,
|
||||
$role,
|
||||
$notes
|
||||
]);
|
||||
$owner_id = $pdo->lastInsertId();
|
||||
$owner_name = $owner_type === "individual" ? "$first_name $last_name" : $company_name;
|
||||
echo json_encode([
|
||||
"success" => true,
|
||||
"message" => "Proprietario aggiunto con successo!",
|
||||
"owner_id" => $owner_id,
|
||||
"owner_name" => $owner_name,
|
||||
"tax_code" => $tax_code
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
die(json_encode(["success" => false, "message" => "Errore nell'inserimento: " . $e->getMessage()]));
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
ini_set('display_errors', 1);
|
||||
ini_set('display_startup_errors', 1);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once __DIR__ . '/class/db-functions.php';
|
||||
$dbHandler = DBHandlerSelect::getInstance();
|
||||
$pdo = $dbHandler->getConnection();
|
||||
|
||||
// Recupera i dati inviati tramite POST
|
||||
$idhome = isset($_POST['idhome']) ? (int)$_POST['idhome'] : 0;
|
||||
$owner_id = isset($_POST['owner_id']) ? (int)$_POST['owner_id'] : 0;
|
||||
$ownership_percentage = isset($_POST['ownership_percentage']) ? (float)$_POST['ownership_percentage'] : null;
|
||||
$notes = isset($_POST['notes']) ? htmlspecialchars($_POST['notes']) : null;
|
||||
|
||||
// Verifica che tutti i dati siano presenti
|
||||
if ($idhome <= 0 || $owner_id <= 0 || is_null($ownership_percentage)) {
|
||||
die(json_encode(["success" => false, "message" => "Errore: Dati mancanti."]));
|
||||
}
|
||||
|
||||
// Controlla se il proprietario è già associato alla casa
|
||||
$stmt = $pdo->prepare("SELECT 1 FROM home_owners WHERE home_id = ? AND owner_id = ?");
|
||||
$stmt->execute([$idhome, $owner_id]);
|
||||
if ($stmt->fetch()) {
|
||||
die(json_encode(["success" => false, "message" => "Errore: Il proprietario è già associato a questa casa."]));
|
||||
}
|
||||
|
||||
// Calcola la somma attuale delle percentuali di proprietà
|
||||
$stmt = $pdo->prepare("SELECT SUM(ownership_percentage) FROM home_owners WHERE home_id = ?");
|
||||
$stmt->execute([$idhome]);
|
||||
$currentTotal = $stmt->fetchColumn() ?: 0;
|
||||
|
||||
$totalAfterInsert = $currentTotal + $ownership_percentage;
|
||||
if ($totalAfterInsert > 100) {
|
||||
die(json_encode(["success" => false, "message" => "Errore: La somma totale dei proprietari supererebbe il 100%. Totale attuale: $currentTotal%, percentuale richiesta: $ownership_percentage%."]));
|
||||
}
|
||||
|
||||
// Inserisce il nuovo proprietario nella tabella home_owners
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO home_owners (home_id, owner_id, ownership_percentage, notes, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, NOW(), NOW())
|
||||
");
|
||||
try {
|
||||
$stmt->execute([$idhome, $owner_id, $ownership_percentage, $notes]);
|
||||
echo json_encode(["success" => true, "message" => "Proprietario aggiunto con successo."]);
|
||||
} catch (PDOException $e) {
|
||||
die(json_encode(["success" => false, "message" => "Errore nell'inserimento: " . $e->getMessage()]));
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
include('include/headscript.php');
|
||||
$conn = new mysqli($servername, $username, $password, $database);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
die("Errore di connessione: " . $conn->connect_error);
|
||||
}
|
||||
|
||||
// Recupera i dati dal form
|
||||
$namepages = $_POST['namepages'];
|
||||
$slug = $_POST['slug'];
|
||||
$descriptionpages = $_POST['descriptionpages'];
|
||||
|
||||
// Inserisce la nuova pagina nel database
|
||||
$query = $conn->prepare("INSERT INTO pages (namepages, slug, descriptionpages) VALUES (?, ?, ?)");
|
||||
$query->bind_param('sss', $namepages, $slug, $descriptionpages);
|
||||
|
||||
if ($query->execute()) {
|
||||
header("Location: page-settings.php?success=1");
|
||||
} else {
|
||||
header("Location: page-settings.php?error=1");
|
||||
}
|
||||
|
||||
$query->close();
|
||||
$conn->close();
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
include('include/headscript.php');
|
||||
|
||||
// Abilita debug
|
||||
ini_set('display_errors', 1);
|
||||
ini_set('display_startup_errors', 1);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Connessione al database
|
||||
$conn = new mysqli($servername, $username, $password, $database);
|
||||
if ($conn->connect_error) {
|
||||
die("Errore di connessione: " . $conn->connect_error);
|
||||
}
|
||||
|
||||
// Controlla il metodo della richiesta
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// Recupera i dati dal form
|
||||
$role_name = trim($_POST['role_name']);
|
||||
$description = trim($_POST['description']);
|
||||
$permissions = trim($_POST['permissions']);
|
||||
|
||||
|
||||
|
||||
// Prepara e esegue la query
|
||||
$stmt = $conn->prepare("INSERT INTO sharing_roles (`role_name`, `description`, `permissions`) VALUES (?, ?, ?)");
|
||||
if ($stmt) {
|
||||
$stmt->bind_param('sss', $role_name, $description, $permissions);
|
||||
if ($stmt->execute()) {
|
||||
header("Location: sharing-roles.php?success=1");
|
||||
exit;
|
||||
} else {
|
||||
die("Errore di esecuzione: " . $stmt->error);
|
||||
}
|
||||
$stmt->close();
|
||||
} else {
|
||||
die("Errore nella preparazione della query: " . $conn->error);
|
||||
}
|
||||
}
|
||||
$conn->close();
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
// Mostra errori per il debug
|
||||
ini_set('display_errors', 1);
|
||||
ini_set('display_startup_errors', 1);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
include('include/headscript.php');
|
||||
|
||||
// Connessione al database
|
||||
$conn = new mysqli($servername, $username, $password, $database);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
die("Errore di connessione: " . $conn->connect_error);
|
||||
}
|
||||
|
||||
// Controlla se il metodo della richiesta è POST
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// Recupera i dati dal form
|
||||
$section_name = $conn->real_escape_string($_POST['section_name']);
|
||||
$description = $conn->real_escape_string($_POST['description']);
|
||||
|
||||
// Prepara la query per l'inserimento
|
||||
$query = "INSERT INTO sections (section_name, description) VALUES (?, ?)";
|
||||
$stmt = $conn->prepare($query);
|
||||
|
||||
if ($stmt === false) {
|
||||
die("Errore nella preparazione della query: " . $conn->error);
|
||||
}
|
||||
|
||||
// Associa i parametri alla query
|
||||
$stmt->bind_param('ss', $section_name, $description);
|
||||
|
||||
// Esegue la query
|
||||
if ($stmt->execute()) {
|
||||
// Reindirizza alla pagina sections.php con un messaggio di successo
|
||||
header("Location: sections.php?success=1");
|
||||
exit();
|
||||
} else {
|
||||
// Reindirizza alla pagina sections.php con un messaggio di errore
|
||||
header("Location: sections.php?error=1");
|
||||
exit();
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
} else {
|
||||
// Metodo non consentito
|
||||
header("HTTP/1.1 405 Method Not Allowed");
|
||||
echo "Metodo non consentito.";
|
||||
exit();
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
@@ -1,187 +0,0 @@
|
||||
<?php include('include/headscript.php'); ?>
|
||||
<?php
|
||||
$conn = new mysqli($servername, $username, $password, $database);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
die("Errore di connessione: " . $conn->connect_error);
|
||||
}
|
||||
|
||||
// Recupera idhome
|
||||
$idhome = isset($_GET['idhome']) ? (int)$_GET['idhome'] : 0;
|
||||
|
||||
// Recupera le sezioni dalla tabella 'sections'
|
||||
$querySections = "SELECT idsections, section_name FROM sections ORDER BY section_name ASC";
|
||||
$resultSections = $conn->query($querySections);
|
||||
$sections = [];
|
||||
if ($resultSections->num_rows > 0) {
|
||||
while ($row = $resultSections->fetch_assoc()) {
|
||||
$sections[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
// Recupera tutti i ruoli per la dropdown
|
||||
$queryRoles = "SELECT idrole, role_name FROM sharing_roles";
|
||||
$resultRoles = $conn->query($queryRoles);
|
||||
$roles = [];
|
||||
if ($resultRoles->num_rows > 0) {
|
||||
while ($row = $resultRoles->fetch_assoc()) {
|
||||
$roles[] = $row;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=0, minimal-ui">
|
||||
<title>Aggiungi Condivisione</title>
|
||||
<link href="assets/css/bootstrap.min.css" rel="stylesheet" type="text/css">
|
||||
<link href="assets/css/icons.css" rel="stylesheet" type="text/css">
|
||||
<link href="assets/css/style.css" rel="stylesheet" type="text/css">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.form-check-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #0056b3;
|
||||
border-color: #004085;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
border-color: #6c757d;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
border-color: #545b62;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="wrapper">
|
||||
<?php include('include/navigationbar.php'); ?>
|
||||
<div class="content-page">
|
||||
<div class="content">
|
||||
<?php include('include/topbar.php'); ?>
|
||||
<div class="page-content-wrapper">
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="card mt-4">
|
||||
<div class="card-header bg-warning text-white">
|
||||
<h4 class="mb-0">
|
||||
<i class="fas fa-share-alt"></i> Aggiungi Condivisione
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="save-sharing.php" method="POST">
|
||||
<input type="hidden" name="idhome" value="<?php echo $idhome; ?>">
|
||||
|
||||
<!-- Email destinatario -->
|
||||
<div class="form-group">
|
||||
<label for="shared_email" class="form-label">
|
||||
<i class="fas fa-envelope"></i> Email destinatario
|
||||
</label>
|
||||
<input type="email" class="form-control" id="shared_email" name="shared_email" placeholder="Inserisci email del destinatario" required>
|
||||
</div>
|
||||
|
||||
<!-- Ruolo destinatario -->
|
||||
<div class="form-group">
|
||||
<label for="role_id" class="form-label">
|
||||
<i class="fas fa-user-tag"></i> Tipologia destinatario
|
||||
</label>
|
||||
<select class="form-control" id="role_id" name="role_id" required>
|
||||
<option value="">Seleziona un ruolo</option>
|
||||
<?php foreach ($roles as $role) { ?>
|
||||
<option value="<?php echo $role['idrole']; ?>"><?php echo htmlspecialchars($role['role_name']); ?></option>
|
||||
<?php } ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Tipo di condivisione -->
|
||||
<div class="form-group">
|
||||
<label for="sharing_type" class="form-label">
|
||||
<i class="fas fa-lock"></i> Tipo di condivisione
|
||||
</label>
|
||||
<select class="form-control" id="sharing_type" name="sharing_type" required>
|
||||
<option value="read-only">Solo lettura</option>
|
||||
<option value="add-documents">Aggiunta documenti</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Sezioni condivise -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
<i class="fas fa-folder"></i> Sezioni condivise
|
||||
</label>
|
||||
<div>
|
||||
<?php foreach ($sections as $section) { ?>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="section_<?php echo $section['idsections']; ?>" name="shared_sections[]" value="<?php echo $section['idsections']; ?>">
|
||||
<label class="form-check-label" for="section_<?php echo $section['idsections']; ?>">
|
||||
<?php echo htmlspecialchars($section['section_name']); ?>
|
||||
</label>
|
||||
</div>
|
||||
<?php } ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data di scadenza -->
|
||||
<div class="form-group">
|
||||
<label for="expiration_date" class="form-label">
|
||||
<i class="fas fa-calendar-alt"></i> Data di scadenza
|
||||
</label>
|
||||
<input type="date" class="form-control" id="expiration_date" name="expiration_date">
|
||||
</div>
|
||||
|
||||
<!-- Pulsanti -->
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="fas fa-save"></i> Salva Condivisione
|
||||
</button>
|
||||
<a href="share-home.php?idhome=<?php echo $idhome; ?>" class="btn btn-secondary btn-lg">
|
||||
<i class="fas fa-arrow-left"></i> Annulla
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- Page content Wrapper -->
|
||||
</div> <!-- content -->
|
||||
<?php include('include/footer.php'); ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="assets/js/jquery.min.js"></script>
|
||||
<script src="assets/js/bootstrap.min.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,291 +0,0 @@
|
||||
<?php include('include/headscript.php'); ?>
|
||||
<?php
|
||||
// Connessione al database e avvio sessione
|
||||
$conn = new mysqli($servername, $username, $password, $database);
|
||||
|
||||
// Verifica connessione
|
||||
if ($conn->connect_error) {
|
||||
die("Errore di connessione: " . $conn->connect_error);
|
||||
}
|
||||
|
||||
// Recupera l'id utente loggato
|
||||
$iduserlogin = $_SESSION['iduserlogin'];
|
||||
|
||||
// Calcolo del numero di immobili
|
||||
$queryHomes = $conn->prepare("SELECT COUNT(*) AS total_homes FROM home");
|
||||
$queryHomes->execute();
|
||||
$resultHomes = $queryHomes->get_result();
|
||||
$totalHomes = $resultHomes->fetch_assoc()['total_homes'];
|
||||
|
||||
// Calcolo del numero di persone
|
||||
$queryOwners = $conn->prepare("SELECT COUNT(*) AS total_owners FROM property_owners");
|
||||
$queryOwners->execute();
|
||||
$resultOwners = $queryOwners->get_result();
|
||||
$totalOwners = $resultOwners->fetch_assoc()['total_owners'];
|
||||
|
||||
// Numero di documenti (placeholder per ora)
|
||||
// Calcolo del numero di documenti
|
||||
$queryDocuments = $conn->prepare("
|
||||
SELECT COUNT(*) AS total_documents
|
||||
FROM doc_storage
|
||||
LEFT JOIN home ON doc_storage.idhome = home.idhome
|
||||
WHERE home.iduser = ?
|
||||
");
|
||||
$queryDocuments->bind_param('i', $iduserlogin);
|
||||
$queryDocuments->execute();
|
||||
$resultDocuments = $queryDocuments->get_result();
|
||||
$totalDocuments = $resultDocuments->fetch_assoc()['total_documents'];
|
||||
?>
|
||||
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=0, minimal-ui">
|
||||
<?php include('include/seo.php'); ?>
|
||||
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
|
||||
<link rel="shortcut icon" href="assets/images/favicon.ico">
|
||||
|
||||
<link href="assets/css/bootstrap.min.css" rel="stylesheet" type="text/css">
|
||||
<link href="assets/css/icons.css" rel="stylesheet" type="text/css">
|
||||
<link href="assets/css/style.css" rel="stylesheet" type="text/css">
|
||||
<link rel="stylesheet" href="https://cdn.materialdesignicons.com/5.4.55/css/materialdesignicons.min.css">
|
||||
|
||||
</head>
|
||||
<style>
|
||||
/* Visualizza il pulsante mobile anche su desktop */
|
||||
.button-menu-mobile {
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
/* Nascondi il quadrato rosso con la X su desktop */
|
||||
@media (min-width: 992px) {
|
||||
.button-menu-mobile {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.button-menu-mobile i.ion-close {
|
||||
display: none;
|
||||
/* Nasconde la X rossa */
|
||||
}
|
||||
|
||||
.button-menu-mobile i.mdi-menu {
|
||||
display: inline-block;
|
||||
/* Mostra sempre le tre righe orizzontali */
|
||||
}
|
||||
}
|
||||
|
||||
/* Stile per mobile: Mantieni il comportamento originale */
|
||||
@media (max-width: 991.98px) {
|
||||
.button-menu-mobile {
|
||||
background-color: #ff5e5e;
|
||||
/* Questo è il colore rosso originale */
|
||||
border-radius: 50%;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.button-menu-mobile i.ion-close {
|
||||
display: inline-block;
|
||||
/* Mostra la X rossa */
|
||||
}
|
||||
|
||||
.button-menu-mobile i.mdi-menu {
|
||||
display: none;
|
||||
/* Nasconde le tre righe quando la barra è aperta */
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<body class="fixed-left">
|
||||
|
||||
<!-- Loader -->
|
||||
<div id="preloader">
|
||||
<div id="status">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Begin page -->
|
||||
<div id="wrapper">
|
||||
|
||||
<?php include('include/navigationbar.php'); ?>
|
||||
|
||||
<!-- Start right Content here -->
|
||||
|
||||
<div class="content-page">
|
||||
<!-- Start content -->
|
||||
<div class="content">
|
||||
|
||||
<?php include('include/topbar.php'); ?>
|
||||
|
||||
<div class="page-content-wrapper ">
|
||||
|
||||
<div class="container-fluid">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<div class="btn-group float-right">
|
||||
<ol class="breadcrumb hide-phone p-0 m-0">
|
||||
<li class="breadcrumb-item"><a href="#">CasaDoc</a></li>
|
||||
<li class="breadcrumb-item active">Dashboard</li>
|
||||
</ol>
|
||||
</div>
|
||||
<h4 class="page-title">Dashboard</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- end page title end breadcrumb -->
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
<div class="row">
|
||||
<!-- Statistica: Numero di immobili -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card shadow">
|
||||
<div class="card-body">
|
||||
<div class="icon-contain">
|
||||
<div class="row">
|
||||
<div class="col-2 align-self-center">
|
||||
<i class="mdi mdi-home-outline" style="font-size: 2.5rem; color: #85c1e9;"></i>
|
||||
</div>
|
||||
<div class="col-10 text-right">
|
||||
<h5 class="mt-0 mb-1"><?php echo $totalHomes; ?></h5>
|
||||
<p class="mb-0 font-14" style="color: #5d6d7e;">Immobili inseriti</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistica: Numero di persone -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card shadow">
|
||||
<div class="card-body">
|
||||
<div class="icon-contain">
|
||||
<div class="row">
|
||||
<div class="col-2 align-self-center">
|
||||
<i class="mdi mdi-account-outline" style="font-size: 2.5rem; color: #82e0aa;"></i>
|
||||
</div>
|
||||
<div class="col-10 text-right">
|
||||
<h5 class="mt-0 mb-1"><?php echo $totalOwners; ?></h5>
|
||||
<p class="mb-0 font-14" style="color: #5d6d7e;">Proprietari registrati</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistica: Numero di documenti -->
|
||||
<!-- Statistica: Numero di documenti -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card shadow">
|
||||
<div class="card-body">
|
||||
<div class="icon-contain">
|
||||
<div class="row">
|
||||
<div class="col-2 align-self-center">
|
||||
<i class="mdi mdi-file-outline" style="font-size: 2.5rem; color: #f8c471;"></i>
|
||||
</div>
|
||||
<div class="col-10 text-right">
|
||||
<h5 class="mt-0 mb-1"><?php echo $totalDocuments; ?></h5>
|
||||
<p class="mb-0 font-14" style="color: #5d6d7e;">Documenti caricati</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="row text-center mt-4">
|
||||
<div class="col-lg-6 col-md-6 mb-4">
|
||||
<a href="page-settings.php" class="btn btn-warning d-flex flex-column justify-content-center align-items-center py-5 shadow rounded-lg">
|
||||
<i class="mdi mdi-book-outline" style="font-size: 3rem;"></i>
|
||||
<span class="mt-2 fw-bold" style="font-size: 1.25rem;">Gestione Pagine</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-lg-6 col-md-6 mb-4">
|
||||
<a href="documents-settings.php" class="btn btn-danger d-flex flex-column justify-content-center align-items-center py-5 shadow rounded-lg">
|
||||
<i class="mdi mdi-file-cog-outline" style="font-size: 3rem;"></i>
|
||||
<span class="mt-2 fw-bold" style="font-size: 1.25rem;">Gestione Documenti</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row text-center mt-4">
|
||||
<div class="col-lg-6 col-md-6 mb-4">
|
||||
<a href="sharing-roles.php" class="btn btn-info d-flex flex-column justify-content-center align-items-center py-5 shadow rounded-lg">
|
||||
<i class="mdi mdi-book-outline" style="font-size: 3rem;"></i>
|
||||
<span class="mt-2 fw-bold" style="font-size: 1.25rem;">Ruoli Condivisione</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-lg-6 col-md-6 mb-4">
|
||||
<a href="sections.php" class="btn btn-success d-flex flex-column justify-content-center align-items-center py-5 shadow rounded-lg">
|
||||
<i class="mdi mdi-file-cog-outline" style="font-size: 3rem;"></i>
|
||||
<span class="mt-2 fw-bold" style="font-size: 1.25rem;">Sections</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- end row -->
|
||||
|
||||
</div><!-- container -->
|
||||
|
||||
</div> <!-- Page content Wrapper -->
|
||||
|
||||
</div> <!-- content -->
|
||||
|
||||
<?php include('include/footer.php'); ?>
|
||||
|
||||
</div>
|
||||
<!-- End Right content here -->
|
||||
|
||||
</div>
|
||||
<!-- END wrapper -->
|
||||
|
||||
|
||||
<!-- jQuery -->
|
||||
<script src="assets/js/jquery.min.js"></script>
|
||||
<script src="assets/js/popper.min.js"></script>
|
||||
<script src="assets/js/bootstrap.min.js"></script>
|
||||
<script src="assets/js/modernizr.min.js"></script>
|
||||
<script src="assets/js/detect.js"></script>
|
||||
<script src="assets/js/fastclick.js"></script>
|
||||
<script src="assets/js/jquery.slimscroll.js"></script>
|
||||
<script src="assets/js/jquery.blockUI.js"></script>
|
||||
<script src="assets/js/waves.js"></script>
|
||||
<script src="assets/js/jquery.nicescroll.js"></script>
|
||||
<script src="assets/js/jquery.scrollTo.min.js"></script>
|
||||
|
||||
<script src="assets/plugins/chart.js/chart.min.js"></script>
|
||||
<script src="assets/pages/dashboard.js"></script>
|
||||
|
||||
<!-- App js -->
|
||||
<script src="assets/js/app.js"></script>
|
||||
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,423 +0,0 @@
|
||||
<?php
|
||||
// Mobile API bootstrap: PDO, bearer auth, JSON helpers, ownership checks, presenters.
|
||||
// Included first in every endpoint. No Laravel/session.
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$config = require __DIR__ . '/config.php';
|
||||
require_once __DIR__ . '/_debug.php';
|
||||
|
||||
// Never leak PHP errors into the response; any uncaught throwable becomes a JSON 500.
|
||||
ini_set('display_errors', '0');
|
||||
set_exception_handler(function (Throwable $e): void {
|
||||
error_log('[casadoc-api] ' . $e);
|
||||
// TEMPORARY.
|
||||
debug_log('[' . ($_SERVER['REQUEST_URI'] ?? '?') . '] uncaught: ' . $e);
|
||||
if (!headers_sent()) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
}
|
||||
echo json_encode(['error' => ['code' => 'error', 'message' => 'Internal server error']]);
|
||||
});
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Headers: Authorization, Content-Type');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = new PDO(
|
||||
"mysql:host={$config['db_host']};port={$config['db_port']};dbname={$config['db_name']};charset=utf8mb4",
|
||||
$config['db_user'],
|
||||
$config['db_pass'],
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => ['code' => 'error', 'message' => 'Database connection failed']]);
|
||||
exit;
|
||||
}
|
||||
|
||||
function json_data(mixed $data, int $code = 200): never
|
||||
{
|
||||
http_response_code($code);
|
||||
echo json_encode(['data' => $data], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
function json_ok(): never
|
||||
{
|
||||
echo json_encode(['success' => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
/** $errorCode overrides the status-derived code, e.g. code_expired or email_not_verified. */
|
||||
function json_error(int $code, string $message, ?array $fields = null, ?string $errorCode = null): never
|
||||
{
|
||||
static $codes = [
|
||||
400 => 'bad_request', 401 => 'unauthorized', 403 => 'forbidden',
|
||||
404 => 'not_found', 405 => 'method_not_allowed', 422 => 'validation',
|
||||
];
|
||||
http_response_code($code);
|
||||
echo json_encode(['error' => array_filter([
|
||||
'code' => $errorCode ?? ($codes[$code] ?? 'error'),
|
||||
'message' => $message,
|
||||
'fields' => $fields,
|
||||
], fn ($v) => $v !== null)], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
function require_method(string $method): void
|
||||
{
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== $method) {
|
||||
json_error(405, "Method not allowed, use {$method}");
|
||||
}
|
||||
}
|
||||
|
||||
// Request body: JSON or form-data.
|
||||
function body(): array
|
||||
{
|
||||
$raw = json_decode(file_get_contents('php://input') ?: '', true);
|
||||
if (is_array($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
return $_POST;
|
||||
}
|
||||
|
||||
function query(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $_GET[$key] ?? $default;
|
||||
}
|
||||
|
||||
function bearer_token(): ?string
|
||||
{
|
||||
$header = $_SERVER['HTTP_AUTHORIZATION']
|
||||
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
|
||||
?? '';
|
||||
if (!$header && function_exists('getallheaders')) {
|
||||
foreach (getallheaders() as $k => $v) {
|
||||
if (strcasecmp($k, 'Authorization') === 0) {
|
||||
$header = $v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return preg_match('/Bearer\s+(\S+)/i', $header, $m) ? $m[1] : null;
|
||||
}
|
||||
|
||||
// Validate the token and return the auth_users row, or respond 401.
|
||||
function require_auth(PDO $pdo): array
|
||||
{
|
||||
$token = bearer_token();
|
||||
if (!$token) {
|
||||
json_error(401, 'Missing bearer token');
|
||||
}
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT u.* FROM api_tokens t
|
||||
JOIN auth_users u ON u.id = t.user_id
|
||||
WHERE t.token = ? AND (t.expires_at IS NULL OR t.expires_at > NOW())
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([hash('sha256', $token)]);
|
||||
$user = $stmt->fetch();
|
||||
if (!$user) {
|
||||
json_error(401, 'Invalid or expired token');
|
||||
}
|
||||
$pdo->prepare('UPDATE api_tokens SET last_used_at = NOW() WHERE token = ?')
|
||||
->execute([hash('sha256', $token)]);
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads are scans: PDF or images. The type is sniffed from the content, not from the
|
||||
* client-declared Content-Type (mobile clients often send application/octet-stream).
|
||||
* The extension fallback covers formats an older libmagic may not know, e.g. HEIC.
|
||||
*/
|
||||
function is_allowed_upload(string $tmpPath, string $originalName, bool $imagesOnly = false): bool
|
||||
{
|
||||
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($tmpPath) ?: '';
|
||||
if (str_starts_with($mime, 'image/') || (!$imagesOnly && $mime === 'application/pdf')) {
|
||||
return true;
|
||||
}
|
||||
$images = ['jpg', 'jpeg', 'png', 'heic', 'heif', 'webp', 'gif', 'tif', 'tiff'];
|
||||
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
|
||||
return in_array($ext, $imagesOnly ? $images : [...$images, 'pdf'], true);
|
||||
}
|
||||
|
||||
function user_owns_home(PDO $pdo, array $user, int $idhome): bool
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT 1 FROM home WHERE idhome = ? AND iduser = ? LIMIT 1');
|
||||
$stmt->execute([$idhome, $user['id']]);
|
||||
return (bool) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
// Ownership OR an accepted, non-expired share.
|
||||
function user_can_access_home(PDO $pdo, array $user, int $idhome): bool
|
||||
{
|
||||
if (user_owns_home($pdo, $user, $idhome)) {
|
||||
return true;
|
||||
}
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT 1 FROM home_sharing
|
||||
WHERE idhome = ? AND (idshareduser = ? OR shared_email = ?)
|
||||
AND status = 'accepted'
|
||||
AND (expiration_date IS NULL OR expiration_date >= CURDATE())
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$idhome, $user['id'], $user['email']]);
|
||||
return (bool) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
function user_owns_owner(PDO $pdo, array $user, int $ownerId): bool
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT 1 FROM property_owners WHERE owner_id = ? AND user_id = ? LIMIT 1');
|
||||
$stmt->execute([$ownerId, $user['id']]);
|
||||
return (bool) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
// Absolute base URL taken from the current request so generated links match the
|
||||
// host the client actually used; falls back to APP_URL (e.g. when run from CLI).
|
||||
function base_url(): string
|
||||
{
|
||||
global $config;
|
||||
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||
if ($host === '') {
|
||||
return $config['app_url'];
|
||||
}
|
||||
$https = ($_SERVER['HTTPS'] ?? '') !== '' && ($_SERVER['HTTPS'] ?? '') !== 'off';
|
||||
$proto = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ($https ? 'https' : 'http');
|
||||
return $proto . '://' . $host;
|
||||
}
|
||||
|
||||
// URL for a public/ asset. Uses the configured public base (ASSETS_BASE_URL) so links
|
||||
// point at the real https domain even behind a proxy; falls back to the request host.
|
||||
function asset_url(string $path): string
|
||||
{
|
||||
global $config;
|
||||
$base = $config['assets_base_url'] ?: base_url();
|
||||
return $base . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
function present_user(array $u): array
|
||||
{
|
||||
// Uploaded avatars are stored as a bare filename under public/upload/users;
|
||||
// social logins already hold an absolute URL.
|
||||
$avatar = $u['avatar'] ?? null;
|
||||
if ($avatar && !str_starts_with($avatar, 'http')) {
|
||||
$avatar = asset_url('/upload/users/' . $avatar);
|
||||
}
|
||||
return [
|
||||
'id' => (int) $u['id'],
|
||||
'first_name' => $u['first_name'] ?? null,
|
||||
'last_name' => $u['last_name'] ?? null,
|
||||
'email' => $u['email'] ?? null,
|
||||
'username' => $u['username'] ?? null,
|
||||
'phone' => $u['phone'] ?? null,
|
||||
'address' => $u['address'] ?? null,
|
||||
'avatar' => $avatar,
|
||||
];
|
||||
}
|
||||
|
||||
// Documents expiring within this many days (or already expired) count as "expiring".
|
||||
const EXPIRING_SOON_DAYS = 60;
|
||||
|
||||
/** One query for a set of homes: idhome => [documents_count, expiring_count]. */
|
||||
function home_counts(PDO $pdo, array $idhomes): array
|
||||
{
|
||||
$idhomes = array_values(array_unique(array_map('intval', $idhomes)));
|
||||
if (!$idhomes) {
|
||||
return [];
|
||||
}
|
||||
$in = implode(',', array_fill(0, count($idhomes), '?'));
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT idhome,
|
||||
COUNT(*) AS documents_count,
|
||||
SUM(expirydate IS NOT NULL
|
||||
AND expirydate <= DATE_ADD(CURDATE(), INTERVAL " . EXPIRING_SOON_DAYS . " DAY)) AS expiring_count
|
||||
FROM doc_storage
|
||||
WHERE idhome IN ($in)
|
||||
GROUP BY idhome"
|
||||
);
|
||||
$stmt->execute($idhomes);
|
||||
|
||||
$counts = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$counts[(int) $row['idhome']] = [
|
||||
'documents_count' => (int) $row['documents_count'],
|
||||
'expiring_count' => (int) $row['expiring_count'],
|
||||
];
|
||||
}
|
||||
return $counts;
|
||||
}
|
||||
|
||||
function present_home(array $h, bool $isOwner = true, ?array $counts = null): array
|
||||
{
|
||||
$photo = $h['mainphoto'] ?? null;
|
||||
return [
|
||||
'idhome' => (int) $h['idhome'],
|
||||
'name' => $h['name'] ?? null,
|
||||
'comment' => $h['comment'] ?? null,
|
||||
'fulladdress' => $h['fulladdress'] ?? null,
|
||||
'address' => $h['address'] ?? null,
|
||||
'zip' => $h['zip'] ?? null,
|
||||
'city' => $h['city'] ?? null,
|
||||
'country' => $h['country'] ?? null,
|
||||
'latitude' => $h['latitude'] ?? null,
|
||||
'longitude' => $h['longitude'] ?? null,
|
||||
'mainphoto' => $photo,
|
||||
'photo_url' => $photo ? asset_url('/userportal/mainphoto/' . $photo) : null,
|
||||
'cadastral_municipality' => $h['cadastral_municipality'] ?? null,
|
||||
'cadastral_section' => $h['cadastral_section'] ?? null,
|
||||
'cadastral_sheet' => $h['cadastral_sheet'] ?? null,
|
||||
'cadastral_particle' => $h['cadastral_particle'] ?? null,
|
||||
'cadastral_sub' => $h['cadastral_sub'] ?? null,
|
||||
'cadastral_category' => $h['cadastral_category'] ?? null,
|
||||
'cadastral_class' => $h['cadastral_class'] ?? null,
|
||||
'cadastral_surface' => $h['cadastral_surface'] ?? null,
|
||||
'cadastral_rendita' => $h['cadastral_rendita'] ?? null,
|
||||
'cadastral_notes' => $h['cadastral_notes'] ?? null,
|
||||
'is_owner' => $isOwner,
|
||||
'documents_count' => (int) ($counts['documents_count'] ?? 0),
|
||||
'expiring_count' => (int) ($counts['expiring_count'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
function present_file(array $f): array
|
||||
{
|
||||
global $config;
|
||||
$isPerson = ($f['entity_type'] ?? '') === 'person' || empty($f['idhome']);
|
||||
$path = ($isPerson ? $config['persondocs_dir'] : $config['homedocs_dir'])
|
||||
. '/' . basename((string) $f['filename']);
|
||||
return [
|
||||
'id' => (int) $f['id'],
|
||||
'document_id' => (int) $f['document_id'],
|
||||
'title' => $f['title'] ?? null,
|
||||
'idhome' => isset($f['idhome']) ? (int) $f['idhome'] : null,
|
||||
'owner_id' => isset($f['owner_id']) ? (int) $f['owner_id'] : null,
|
||||
'filename' => $f['filename'],
|
||||
'size' => is_file($path) ? filesize($path) : null,
|
||||
'url' => asset_url('/userportal/api/document-file.php?id=' . (int) $f['id']),
|
||||
'expiry_date' => $f['expirydate'] ?? null,
|
||||
'expiry_status' => isset($f['expirystatus']) ? (int) $f['expirystatus'] : null,
|
||||
'note' => $f['note'] ?? null,
|
||||
'created_at' => $f['created_at'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
function present_owner(array $o): array
|
||||
{
|
||||
return [
|
||||
'owner_id' => (int) $o['owner_id'],
|
||||
'owner_type' => $o['owner_type'] ?? null,
|
||||
'first_name' => $o['first_name'] ?? null,
|
||||
'last_name' => $o['last_name'] ?? null,
|
||||
'company_name' => $o['company_name'] ?? null,
|
||||
'tax_code' => $o['tax_code'] ?? null,
|
||||
'email' => $o['email'] ?? null,
|
||||
'phone' => $o['phone'] ?? null,
|
||||
'address' => $o['address'] ?? null,
|
||||
'postal_code' => $o['postal_code'] ?? null,
|
||||
'city' => $o['city'] ?? null,
|
||||
'province' => $o['province'] ?? null,
|
||||
'country' => isset($o['country']) ? (int) $o['country'] : null,
|
||||
'role' => $o['role'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
// property_owners row plus home_owners fields (ownership_percentage, notes).
|
||||
function present_home_owner(array $row): array
|
||||
{
|
||||
return [
|
||||
'owner' => present_owner($row),
|
||||
'ownership_percentage' => isset($row['ownership_percentage']) ? (float) $row['ownership_percentage'] : null,
|
||||
'notes' => $row['notes'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
function present_section(array $s): array
|
||||
{
|
||||
return [
|
||||
'idsections' => (int) $s['idsections'],
|
||||
'section_name' => $s['section_name'] ?? null,
|
||||
'description' => $s['description'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
function present_page(array $p): array
|
||||
{
|
||||
return [
|
||||
'idpages' => (int) $p['idpages'],
|
||||
'namepages' => $p['namepages'] ?? null,
|
||||
'slug' => $p['slug'] ?? null,
|
||||
'descriptionpages' => $p['descriptionpages'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
function present_document_template(array $d): array
|
||||
{
|
||||
return [
|
||||
'document_id' => (int) $d['document_id'],
|
||||
'document_name' => $d['document_name'] ?? null,
|
||||
'page_id' => isset($d['page_id']) ? (int) $d['page_id'] : null,
|
||||
'idsections' => isset($d['idsections']) ? (int) $d['idsections'] : null,
|
||||
'section_name' => $d['section_name'] ?? null,
|
||||
'max_documents' => (int) ($d['max_documents'] ?? 0),
|
||||
'is_required' => (bool) ($d['is_required'] ?? 0),
|
||||
'notes' => $d['notes'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
function present_sharing_role(array $r): array
|
||||
{
|
||||
$perms = json_decode((string) ($r['permissions'] ?? ''), true);
|
||||
return [
|
||||
'idrole' => (int) $r['idrole'],
|
||||
'role_name' => $r['role_name'] ?? null,
|
||||
'description' => $r['description'] ?? null,
|
||||
'permissions' => is_array($perms) ? $perms : [],
|
||||
];
|
||||
}
|
||||
|
||||
// home_sharing row; role is joined via role_name/role_description/role_permissions aliases.
|
||||
function present_share(array $s): array
|
||||
{
|
||||
$sections = json_decode((string) ($s['shared_sections'] ?? ''), true);
|
||||
$role = null;
|
||||
if (!empty($s['role_id'])) {
|
||||
$perms = json_decode((string) ($s['role_permissions'] ?? ''), true);
|
||||
$role = [
|
||||
'idrole' => (int) $s['role_id'],
|
||||
'role_name' => $s['role_name'] ?? null,
|
||||
'description' => $s['role_description'] ?? null,
|
||||
'permissions' => is_array($perms) ? $perms : [],
|
||||
];
|
||||
}
|
||||
return [
|
||||
'idsharing' => (int) $s['idsharing'],
|
||||
'idhome' => (int) $s['idhome'],
|
||||
'shared_email' => $s['shared_email'] ?? null,
|
||||
'idshareduser' => isset($s['idshareduser']) ? (int) $s['idshareduser'] : null,
|
||||
'role' => $role,
|
||||
'sharing_type' => $s['sharing_type'] ?? null,
|
||||
'shared_sections' => is_array($sections) ? array_map('intval', $sections) : [],
|
||||
'expiration_date' => $s['expiration_date'] ?? null,
|
||||
'status' => $s['status'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
// Ownership of a share (I am the one who shared).
|
||||
function user_owns_share(PDO $pdo, array $user, int $idsharing): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM home_sharing WHERE idsharing = ? AND iduser = ? LIMIT 1');
|
||||
$stmt->execute([$idsharing, $user['id']]);
|
||||
$row = $stmt->fetch();
|
||||
return $row ?: null;
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
<?php
|
||||
// Shared rules for the 6-digit codes used by e-mail verification and password reset.
|
||||
// Both tables (auth_email_verifications, auth_password_resets) behave identically:
|
||||
// sha256 of the code is stored, it lives 15 minutes, allows 5 attempts and may be
|
||||
// re-sent at most once per 60 seconds.
|
||||
|
||||
const CODE_TTL_MINUTES = 15;
|
||||
const CODE_MAX_ATTEMPTS = 5;
|
||||
const CODE_RESEND_SECONDS = 60;
|
||||
|
||||
function generate_code(): string
|
||||
{
|
||||
return (string) random_int(100000, 999999);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a fresh code, resetting attempts. Returns the plain code, or null when the
|
||||
* previous one was issued less than CODE_RESEND_SECONDS ago (caller stays silent).
|
||||
*/
|
||||
function issue_code(PDO $pdo, string $table, string $email): ?string
|
||||
{
|
||||
$stmt = $pdo->prepare("SELECT TIMESTAMPDIFF(SECOND, created_at, NOW()) FROM {$table} WHERE email = ? LIMIT 1");
|
||||
$stmt->execute([$email]);
|
||||
$age = $stmt->fetchColumn();
|
||||
if ($age !== false && (int) $age < CODE_RESEND_SECONDS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$code = generate_code();
|
||||
$pdo->prepare("DELETE FROM {$table} WHERE email = ?")->execute([$email]);
|
||||
$pdo->prepare("INSERT INTO {$table} (email, token, attempts, created_at) VALUES (?, ?, 0, NOW())")
|
||||
->execute([$email, hash('sha256', $code)]);
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a code and consumes an attempt. Returns 'ok', 'code_invalid' or 'code_expired'.
|
||||
* A spent or exhausted record is deleted, so the caller must request a new code.
|
||||
*/
|
||||
function check_code(PDO $pdo, string $table, string $email, string $code): string
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT token, attempts, TIMESTAMPDIFF(MINUTE, created_at, NOW()) AS age
|
||||
FROM {$table} WHERE email = ? LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$email]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if (!$row) {
|
||||
return 'code_invalid';
|
||||
}
|
||||
if ((int) $row['age'] >= CODE_TTL_MINUTES) {
|
||||
$pdo->prepare("DELETE FROM {$table} WHERE email = ?")->execute([$email]);
|
||||
return 'code_expired';
|
||||
}
|
||||
if (!hash_equals((string) $row['token'], hash('sha256', $code))) {
|
||||
$attempts = (int) $row['attempts'] + 1;
|
||||
if ($attempts >= CODE_MAX_ATTEMPTS) {
|
||||
$pdo->prepare("DELETE FROM {$table} WHERE email = ?")->execute([$email]);
|
||||
return 'code_expired';
|
||||
}
|
||||
$pdo->prepare("UPDATE {$table} SET attempts = ? WHERE email = ?")->execute([$attempts, $email]);
|
||||
return 'code_invalid';
|
||||
}
|
||||
|
||||
$pdo->prepare("DELETE FROM {$table} WHERE email = ?")->execute([$email]);
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
/** Issues a bearer token exactly like login.php does. */
|
||||
function issue_token(PDO $pdo, int $userId, string $deviceName): string
|
||||
{
|
||||
$config = require __DIR__ . '/config.php';
|
||||
$plain = bin2hex(random_bytes(32));
|
||||
$expires = (new DateTimeImmutable("+{$config['token_ttl_days']} days"))->format('Y-m-d H:i:s');
|
||||
|
||||
$pdo->prepare(
|
||||
'INSERT INTO api_tokens (user_id, name, token, expires_at, created_at) VALUES (?, ?, ?, ?, NOW())'
|
||||
)->execute([$userId, $deviceName, hash('sha256', $plain), $expires]);
|
||||
|
||||
return $plain;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
// TEMPORARY
|
||||
/** Appends one timestamped line. Silent if the file cannot be written. */
|
||||
function debug_log(string $message): void
|
||||
{
|
||||
$path = dirname(__DIR__, 3) . '/storage/logs/casadoc-api.log';
|
||||
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@file_put_contents(
|
||||
$path,
|
||||
'[' . date('Y-m-d H:i:s') . '] ' . rtrim($message) . "\n",
|
||||
FILE_APPEND | LOCK_EX
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
<?php
|
||||
// SMTP delivery via PHPMailer (already a project dependency).
|
||||
|
||||
require_once __DIR__ . '/../../../vendor/autoload.php';
|
||||
require_once __DIR__ . '/_debug.php';
|
||||
|
||||
use Dotenv\Dotenv;
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\SMTP;
|
||||
|
||||
Dotenv::createImmutable([dirname(__DIR__, 2), dirname(__DIR__, 3)])->safeLoad();
|
||||
|
||||
/** Treats an empty value and the literal "null" from .env as "not set". */
|
||||
function mail_env(string $key, string $default = ''): string
|
||||
{
|
||||
$value = (string) ($_ENV[$key] ?? getenv($key) ?: '');
|
||||
|
||||
return ($value === '' || $value === 'null') ? $default : $value;
|
||||
}
|
||||
|
||||
function mail_encryption(int $port): string
|
||||
{
|
||||
$enc = strtolower(mail_env('MAIL_ENCRYPTION'));
|
||||
if ($enc === 'ssl' || $enc === 'tls') {
|
||||
return $enc;
|
||||
}
|
||||
if ($enc === 'none') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return in_array($port, [465, 2465], true) ? 'ssl' : 'tls';
|
||||
}
|
||||
|
||||
/** Never surfaces the SMTP error to the caller: it can leak host and credentials. */
|
||||
function send_mail(string $to, string $subject, string $html): bool
|
||||
{
|
||||
$port = (int) mail_env('MAIL_PORT', '587');
|
||||
$enc = mail_encryption($port);
|
||||
$where = sprintf(
|
||||
'%s@%s:%s (%s)',
|
||||
mail_env('MAIL_USERNAME'),
|
||||
mail_env('MAIL_HOST', 'localhost'),
|
||||
$port,
|
||||
$enc ?: 'none'
|
||||
);
|
||||
|
||||
$mail = new PHPMailer(true);
|
||||
try {
|
||||
$mail->isSMTP();
|
||||
$mail->Host = mail_env('MAIL_HOST', 'localhost');
|
||||
$mail->Port = $port;
|
||||
$mail->SMTPAuth = true;
|
||||
$mail->Username = mail_env('MAIL_USERNAME');
|
||||
$mail->Password = mail_env('MAIL_PASSWORD');
|
||||
$mail->SMTPSecure = $enc;
|
||||
$mail->CharSet = 'UTF-8';
|
||||
$mail->Timeout = 10;
|
||||
$mail->getSMTPInstance()->Timelimit = 15;
|
||||
|
||||
$from = mail_env('MAIL_FROM_ADDRESS', 'noreply@casadoc.app');
|
||||
// Without this the Message-ID is generated as <...@localhost>, which some
|
||||
// providers treat as a spam signal.
|
||||
if ($domain = substr(strrchr($from, '@') ?: '', 1)) {
|
||||
$mail->Hostname = $domain;
|
||||
}
|
||||
|
||||
$mail->setFrom($from, mail_env('MAIL_FROM_NAME', 'CasaDoc'));
|
||||
$mail->addAddress($to);
|
||||
$mail->isHTML(true);
|
||||
$mail->Subject = $subject;
|
||||
$mail->Body = $html;
|
||||
|
||||
// TEMPORARY
|
||||
$queued = '';
|
||||
$mail->SMTPDebug = SMTP::DEBUG_CONNECTION;
|
||||
$mail->Debugoutput = static function (string $str, int $level) use (&$queued): void {
|
||||
foreach (preg_split('/\R/', rtrim($str)) as $line) {
|
||||
if (trim($line) === '') {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/SERVER -> CLIENT: (250 (?!SIZE)\S.*)$/', $line, $m)) {
|
||||
$queued = trim($m[1]);
|
||||
}
|
||||
debug_log(" smtp[{$level}] " . rtrim($line));
|
||||
}
|
||||
};
|
||||
debug_log(sprintf(
|
||||
'mail: sending to %s via %s | from=%s | subject=%s | %d bytes html',
|
||||
$to,
|
||||
$where,
|
||||
$from,
|
||||
$subject,
|
||||
strlen($html)
|
||||
));
|
||||
|
||||
$started = microtime(true);
|
||||
$mail->send();
|
||||
|
||||
debug_log(sprintf(
|
||||
'mail: SENT to %s in %d ms | message-id=%s | accepted by server: %s',
|
||||
$to,
|
||||
(int) round((microtime(true) - $started) * 1000),
|
||||
$mail->getLastMessageID() ?: '(none)',
|
||||
$queued ?: '(not captured)'
|
||||
));
|
||||
|
||||
return true;
|
||||
} catch (Throwable $e) {
|
||||
// Without the connection details a failure is indistinguishable from a
|
||||
// wrong recipient address. The password is never logged.
|
||||
$reason = sprintf(
|
||||
'mail FAILED to %s via %s: %s',
|
||||
$to,
|
||||
$where,
|
||||
$mail->ErrorInfo ?: $e->getMessage()
|
||||
);
|
||||
error_log($reason);
|
||||
debug_log($reason);
|
||||
if ($e->getMessage() !== '' && $e->getMessage() !== $mail->ErrorInfo) {
|
||||
debug_log(' exception: ' . get_class($e) . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function send_code_mail(string $to, string $code, bool $isReset = false): bool
|
||||
{
|
||||
$subject = $isReset ? 'CasaDoc password reset code' : 'CasaDoc verification code';
|
||||
$intro = $isReset
|
||||
? 'Use this code to reset your CasaDoc password:'
|
||||
: 'Use this code to confirm your e-mail address:';
|
||||
|
||||
$html = '<p>' . $intro . '</p>'
|
||||
. '<p style="font-size:28px;font-weight:bold;letter-spacing:4px">' . htmlspecialchars($code) . '</p>'
|
||||
. '<p>The code expires in 15 minutes. If you did not request it, ignore this e-mail.</p>';
|
||||
|
||||
debug_log(sprintf(
|
||||
'code: %s code %s for %s',
|
||||
$isReset ? 'password reset' : 'verification',
|
||||
$code,
|
||||
$to
|
||||
));
|
||||
|
||||
return send_mail($to, $subject, $html);
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
<?php
|
||||
// OpenAPI metadata and component schemas for swagger-php.
|
||||
// Annotation-only; does nothing at runtime.
|
||||
|
||||
/**
|
||||
* @OA\OpenApi(
|
||||
* @OA\Info(
|
||||
* title="Casadoc Mobile API (plain PHP)",
|
||||
* version="1.0.0",
|
||||
* description="Mobile REST API in plain PHP. Bearer tokens, uniform JSON."
|
||||
* ),
|
||||
* @OA\Server(url="/userportal/api", description="Casadoc Mobile API")
|
||||
* )
|
||||
*
|
||||
* @OA\SecurityScheme(
|
||||
* securityScheme="bearerAuth",
|
||||
* type="http",
|
||||
* scheme="bearer",
|
||||
* description="Token from POST /login.php. Header: Authorization: Bearer <token>"
|
||||
* )
|
||||
*
|
||||
* @OA\Tag(name="Auth", description="Login, tokens, current user")
|
||||
* @OA\Tag(name="Homes", description="Properties")
|
||||
* @OA\Tag(name="Documents", description="Document requirements, files, sections")
|
||||
* @OA\Tag(name="Owners", description="Owners and home links")
|
||||
* @OA\Tag(name="Sharing", description="Home sharing and invitations")
|
||||
* @OA\Tag(name="Reference", description="Read-only reference data")
|
||||
*
|
||||
* @OA\Response(response="Unauthorized", description="Missing/invalid token",
|
||||
* @OA\JsonContent(ref="#/components/schemas/Error"))
|
||||
* @OA\Response(response="Forbidden", description="No access to resource",
|
||||
* @OA\JsonContent(ref="#/components/schemas/Error"))
|
||||
* @OA\Response(response="NotFound", description="Not found",
|
||||
* @OA\JsonContent(ref="#/components/schemas/Error"))
|
||||
* @OA\Response(response="ValidationError", description="Validation error",
|
||||
* @OA\JsonContent(ref="#/components/schemas/Error"))
|
||||
* @OA\Response(response="Success", description="Success",
|
||||
* @OA\JsonContent(@OA\Property(property="success", type="boolean", example=true)))
|
||||
*/
|
||||
final class OpenApiMeta
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Schema(schema="Error",
|
||||
* @OA\Property(property="error", type="object",
|
||||
* @OA\Property(property="code", type="string", example="forbidden"),
|
||||
* @OA\Property(property="message", type="string"),
|
||||
* @OA\Property(property="fields", type="object", nullable=true,
|
||||
* description="field -> array of errors (422 only)")
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
final class ErrorSchema
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Schema(schema="User",
|
||||
* @OA\Property(property="id", type="integer"),
|
||||
* @OA\Property(property="first_name", type="string", nullable=true),
|
||||
* @OA\Property(property="last_name", type="string", nullable=true),
|
||||
* @OA\Property(property="email", type="string", format="email"),
|
||||
* @OA\Property(property="username", type="string", nullable=true),
|
||||
* @OA\Property(property="phone", type="string", nullable=true),
|
||||
* @OA\Property(property="address", type="string", nullable=true),
|
||||
* @OA\Property(property="avatar", type="string", nullable=true, description="absolute URL")
|
||||
* )
|
||||
*/
|
||||
final class UserSchema
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Schema(schema="Home",
|
||||
* @OA\Property(property="idhome", type="integer"),
|
||||
* @OA\Property(property="name", type="string", nullable=true),
|
||||
* @OA\Property(property="comment", type="string", nullable=true),
|
||||
* @OA\Property(property="fulladdress", type="string", nullable=true),
|
||||
* @OA\Property(property="address", type="string", nullable=true),
|
||||
* @OA\Property(property="zip", type="string", nullable=true),
|
||||
* @OA\Property(property="city", type="string", nullable=true),
|
||||
* @OA\Property(property="country", type="string", nullable=true),
|
||||
* @OA\Property(property="latitude", type="string", nullable=true),
|
||||
* @OA\Property(property="longitude", type="string", nullable=true),
|
||||
* @OA\Property(property="mainphoto", type="string", nullable=true),
|
||||
* @OA\Property(property="photo_url", type="string", nullable=true),
|
||||
* @OA\Property(property="cadastral_municipality", type="string", nullable=true),
|
||||
* @OA\Property(property="cadastral_section", type="string", nullable=true),
|
||||
* @OA\Property(property="cadastral_sheet", type="string", nullable=true),
|
||||
* @OA\Property(property="cadastral_particle", type="string", nullable=true),
|
||||
* @OA\Property(property="cadastral_sub", type="string", nullable=true),
|
||||
* @OA\Property(property="cadastral_category", type="string", nullable=true),
|
||||
* @OA\Property(property="cadastral_class", type="string", nullable=true),
|
||||
* @OA\Property(property="cadastral_surface", type="string", nullable=true),
|
||||
* @OA\Property(property="cadastral_rendita", type="string", nullable=true),
|
||||
* @OA\Property(property="cadastral_notes", type="string", nullable=true),
|
||||
* @OA\Property(property="is_owner", type="boolean"),
|
||||
* @OA\Property(property="documents_count", type="integer", description="uploaded files for the property"),
|
||||
* @OA\Property(property="expiring_count", type="integer", description="expiring within 60 days or already expired")
|
||||
* )
|
||||
*/
|
||||
final class HomeSchema
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Schema(schema="Section",
|
||||
* @OA\Property(property="idsections", type="integer"),
|
||||
* @OA\Property(property="section_name", type="string"),
|
||||
* @OA\Property(property="description", type="string", nullable=true)
|
||||
* )
|
||||
*/
|
||||
final class SectionSchema
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Schema(schema="UploadedFile",
|
||||
* @OA\Property(property="id", type="integer"),
|
||||
* @OA\Property(property="document_id", type="integer"),
|
||||
* @OA\Property(property="title", type="string", nullable=true, description="user-defined display name; falls back to the requirement name"),
|
||||
* @OA\Property(property="idhome", type="integer", nullable=true),
|
||||
* @OA\Property(property="owner_id", type="integer", nullable=true),
|
||||
* @OA\Property(property="filename", type="string"),
|
||||
* @OA\Property(property="size", type="integer", nullable=true, description="bytes on disk, null if missing"),
|
||||
* @OA\Property(property="url", type="string"),
|
||||
* @OA\Property(property="expiry_date", type="string", format="date", nullable=true),
|
||||
* @OA\Property(property="expiry_status", type="integer", nullable=true, description="tinyint 0/1"),
|
||||
* @OA\Property(property="note", type="string", nullable=true),
|
||||
* @OA\Property(property="created_at", type="string", nullable=true)
|
||||
* )
|
||||
*/
|
||||
final class UploadedFileSchema
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Schema(schema="DocumentRequirement",
|
||||
* description="Document template (documents) plus its uploaded files",
|
||||
* @OA\Property(property="document_id", type="integer"),
|
||||
* @OA\Property(property="document_name", type="string"),
|
||||
* @OA\Property(property="page_id", type="integer", nullable=true),
|
||||
* @OA\Property(property="idsections", type="integer", nullable=true),
|
||||
* @OA\Property(property="section_name", type="string", nullable=true),
|
||||
* @OA\Property(property="max_documents", type="integer"),
|
||||
* @OA\Property(property="is_required", type="boolean"),
|
||||
* @OA\Property(property="notes", type="string", nullable=true),
|
||||
* @OA\Property(property="files", type="array", @OA\Items(ref="#/components/schemas/UploadedFile"))
|
||||
* )
|
||||
*/
|
||||
final class DocumentRequirementSchema
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Schema(schema="DocumentTemplate",
|
||||
* @OA\Property(property="document_id", type="integer"),
|
||||
* @OA\Property(property="document_name", type="string"),
|
||||
* @OA\Property(property="page_id", type="integer", nullable=true),
|
||||
* @OA\Property(property="idsections", type="integer", nullable=true),
|
||||
* @OA\Property(property="section_name", type="string", nullable=true),
|
||||
* @OA\Property(property="max_documents", type="integer"),
|
||||
* @OA\Property(property="is_required", type="boolean"),
|
||||
* @OA\Property(property="notes", type="string", nullable=true)
|
||||
* )
|
||||
*/
|
||||
final class DocumentTemplateSchema
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Schema(schema="Page",
|
||||
* @OA\Property(property="idpages", type="integer"),
|
||||
* @OA\Property(property="namepages", type="string"),
|
||||
* @OA\Property(property="slug", type="string"),
|
||||
* @OA\Property(property="descriptionpages", type="string", nullable=true)
|
||||
* )
|
||||
*/
|
||||
final class PageSchema
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Schema(schema="Owner",
|
||||
* @OA\Property(property="owner_id", type="integer"),
|
||||
* @OA\Property(property="owner_type", type="string", enum={"individual","company"}),
|
||||
* @OA\Property(property="first_name", type="string", nullable=true),
|
||||
* @OA\Property(property="last_name", type="string", nullable=true),
|
||||
* @OA\Property(property="company_name", type="string", nullable=true),
|
||||
* @OA\Property(property="tax_code", type="string"),
|
||||
* @OA\Property(property="email", type="string", nullable=true),
|
||||
* @OA\Property(property="phone", type="string", nullable=true),
|
||||
* @OA\Property(property="address", type="string", nullable=true),
|
||||
* @OA\Property(property="postal_code", type="string", nullable=true),
|
||||
* @OA\Property(property="city", type="string", nullable=true),
|
||||
* @OA\Property(property="province", type="string", nullable=true),
|
||||
* @OA\Property(property="country", type="integer", nullable=true),
|
||||
* @OA\Property(property="role", type="string", nullable=true)
|
||||
* )
|
||||
*/
|
||||
final class OwnerSchema
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Schema(schema="HomeOwner",
|
||||
* @OA\Property(property="owner", ref="#/components/schemas/Owner"),
|
||||
* @OA\Property(property="ownership_percentage", type="number", format="float"),
|
||||
* @OA\Property(property="notes", type="string", nullable=true)
|
||||
* )
|
||||
*/
|
||||
final class HomeOwnerSchema
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Schema(schema="SharingRole",
|
||||
* @OA\Property(property="idrole", type="integer"),
|
||||
* @OA\Property(property="role_name", type="string"),
|
||||
* @OA\Property(property="description", type="string", nullable=true),
|
||||
* @OA\Property(property="permissions", type="array", @OA\Items(type="string"))
|
||||
* )
|
||||
*/
|
||||
final class SharingRoleSchema
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Schema(schema="Share",
|
||||
* @OA\Property(property="idsharing", type="integer"),
|
||||
* @OA\Property(property="idhome", type="integer"),
|
||||
* @OA\Property(property="shared_email", type="string", format="email"),
|
||||
* @OA\Property(property="idshareduser", type="integer", nullable=true),
|
||||
* @OA\Property(property="role", ref="#/components/schemas/SharingRole", nullable=true),
|
||||
* @OA\Property(property="sharing_type", type="string"),
|
||||
* @OA\Property(property="shared_sections", type="array", @OA\Items(type="integer")),
|
||||
* @OA\Property(property="expiration_date", type="string", format="date", nullable=true),
|
||||
* @OA\Property(property="status", type="string", enum={"pending","accepted","rejected"})
|
||||
* )
|
||||
*/
|
||||
final class ShareSchema
|
||||
{
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/account-delete.php",
|
||||
* tags={"Auth"},
|
||||
* summary="Delete the account and all related data",
|
||||
* description="Immediate hard delete: documents on disk, homes, owners, sharing, tokens and the user row. Irreversible.",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"confirm"},
|
||||
* @OA\Property(property="confirm", type="boolean", example=true,
|
||||
* description="guard against an accidental call")
|
||||
* )),
|
||||
* @OA\Response(response=200, ref="#/components/responses/Success"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$config = require __DIR__ . '/config.php';
|
||||
|
||||
if (empty(body()['confirm'])) {
|
||||
json_error(422, 'Validation failed', ['confirm' => ['Required']]);
|
||||
}
|
||||
|
||||
$userId = (int) $user['id'];
|
||||
$email = (string) $user['email'];
|
||||
|
||||
// Ids owned by the user.
|
||||
$stmt = $pdo->prepare('SELECT idhome FROM home WHERE iduser = ?');
|
||||
$stmt->execute([$userId]);
|
||||
$homeIds = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
$stmt = $pdo->prepare('SELECT owner_id FROM property_owners WHERE user_id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
$ownerIds = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
// Collect files before the rows disappear; they are unlinked after a successful commit.
|
||||
$files = [];
|
||||
if ($homeIds || $ownerIds) {
|
||||
$where = [];
|
||||
$params = [];
|
||||
if ($homeIds) {
|
||||
$where[] = 'idhome IN (' . implode(',', array_fill(0, count($homeIds), '?')) . ')';
|
||||
$params = array_merge($params, $homeIds);
|
||||
}
|
||||
if ($ownerIds) {
|
||||
$where[] = 'owner_id IN (' . implode(',', array_fill(0, count($ownerIds), '?')) . ')';
|
||||
$params = array_merge($params, $ownerIds);
|
||||
}
|
||||
$stmt = $pdo->prepare('SELECT filename, entity_type, idhome FROM doc_storage WHERE ' . implode(' OR ', $where));
|
||||
$stmt->execute($params);
|
||||
foreach ($stmt->fetchAll() as $f) {
|
||||
$isPerson = ($f['entity_type'] ?? '') === 'person' || empty($f['idhome']);
|
||||
$files[] = ($isPerson ? $config['persondocs_dir'] : $config['homedocs_dir'])
|
||||
. '/' . basename((string) $f['filename']);
|
||||
}
|
||||
}
|
||||
|
||||
$in = fn (array $ids) => implode(',', array_fill(0, count($ids), '?'));
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
// Sharing granted by the user, received by the user, or addressed to their e-mail.
|
||||
$pdo->prepare('DELETE FROM home_sharing WHERE iduser = ? OR idshareduser = ? OR shared_email = ?')
|
||||
->execute([$userId, $userId, $email]);
|
||||
|
||||
if ($homeIds) {
|
||||
$pdo->prepare('DELETE FROM doc_storage WHERE idhome IN (' . $in($homeIds) . ')')->execute($homeIds);
|
||||
$pdo->prepare('DELETE FROM home_owners WHERE home_id IN (' . $in($homeIds) . ')')->execute($homeIds);
|
||||
$pdo->prepare('DELETE FROM home_sharing WHERE idhome IN (' . $in($homeIds) . ')')->execute($homeIds);
|
||||
}
|
||||
if ($ownerIds) {
|
||||
$pdo->prepare('DELETE FROM doc_storage WHERE owner_id IN (' . $in($ownerIds) . ')')->execute($ownerIds);
|
||||
$pdo->prepare('DELETE FROM home_owners WHERE owner_id IN (' . $in($ownerIds) . ')')->execute($ownerIds);
|
||||
}
|
||||
|
||||
$pdo->prepare('DELETE FROM home WHERE iduser = ?')->execute([$userId]);
|
||||
$pdo->prepare('DELETE FROM property_owners WHERE user_id = ?')->execute([$userId]);
|
||||
|
||||
// Credentials and sessions.
|
||||
$pdo->prepare('DELETE FROM api_tokens WHERE user_id = ?')->execute([$userId]);
|
||||
$pdo->prepare('DELETE FROM auth_sessions WHERE user_id = ?')->execute([$userId]);
|
||||
$pdo->prepare('DELETE FROM auth_personal_access_tokens WHERE tokenable_id = ?')->execute([$userId]);
|
||||
$pdo->prepare('DELETE FROM auth_password_resets WHERE email = ?')->execute([$email]);
|
||||
|
||||
// Cascades auth_social_logins, auth_user_activity, auth_announcements.
|
||||
$pdo->prepare('DELETE FROM auth_users WHERE id = ?')->execute([$userId]);
|
||||
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
json_error(500, 'Account deletion failed');
|
||||
}
|
||||
|
||||
// Files last: an orphaned file is harmless, a lost file after a rollback is not.
|
||||
foreach ($files as $path) {
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
json_ok();
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
// Mobile API config. Values come from the environment (same .env as Laravel).
|
||||
|
||||
// Real environment variables win; anything missing is filled from the project .env
|
||||
// (docker-compose only exports DB_*, while MAIL_* live in the .env file).
|
||||
(static function (): void {
|
||||
// config.php is required by several endpoints; parse .env only once per request.
|
||||
if (defined('CASADOC_ENV_LOADED')) {
|
||||
return;
|
||||
}
|
||||
define('CASADOC_ENV_LOADED', true);
|
||||
$envFile = __DIR__ . '/../../../.env';
|
||||
if (!is_readable($envFile)) {
|
||||
return;
|
||||
}
|
||||
foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || $line[0] === '#' || !str_contains($line, '=')) {
|
||||
continue;
|
||||
}
|
||||
[$key, $value] = explode('=', $line, 2);
|
||||
$key = trim($key);
|
||||
$value = trim(trim(trim($value), '"'), "'");
|
||||
if ($key !== '' && getenv($key) === false) {
|
||||
putenv("$key=$value");
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return [
|
||||
'db_host' => getenv('DB_HOST') ?: '127.0.0.1',
|
||||
'db_port' => getenv('DB_PORT') ?: '3306',
|
||||
'db_name' => getenv('DB_DATABASE') ?: 'casadocdb',
|
||||
'db_user' => getenv('DB_USERNAME') ?: 'root',
|
||||
'db_pass' => getenv('DB_PASSWORD') ?: '',
|
||||
|
||||
'token_ttl_days' => 30,
|
||||
|
||||
'homedocs_dir' => __DIR__ . '/../homedocuments',
|
||||
'persondocs_dir' => __DIR__ . '/../persondocuments',
|
||||
'mainphoto_dir' => __DIR__ . '/../mainphoto',
|
||||
'avatars_dir' => __DIR__ . '/../../upload/users',
|
||||
|
||||
'app_url' => rtrim(getenv('APP_URL') ?: '', '/'),
|
||||
|
||||
'assets_base_url' => rtrim(getenv('ASSETS_BASE_URL') ?: '', '/'),
|
||||
];
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/document-file-delete.php",
|
||||
* tags={"Documents"},
|
||||
* summary="Delete an uploaded file",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"id"},
|
||||
* @OA\Property(property="id", type="integer", description="doc_storage.id")
|
||||
* )),
|
||||
* @OA\Response(response=200, ref="#/components/responses/Success"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$config = require __DIR__ . '/config.php';
|
||||
$id = (int) (body()['id'] ?? 0);
|
||||
|
||||
if ($id <= 0) {
|
||||
json_error(422, 'id is required');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM doc_storage WHERE id = ? LIMIT 1');
|
||||
$stmt->execute([$id]);
|
||||
$file = $stmt->fetch();
|
||||
if (!$file) {
|
||||
json_error(404, 'File not found');
|
||||
}
|
||||
|
||||
$allowed = false;
|
||||
$baseDir = null;
|
||||
if (!empty($file['idhome'])) {
|
||||
$allowed = user_owns_home($pdo, $user, (int) $file['idhome']);
|
||||
$baseDir = $config['homedocs_dir'];
|
||||
} elseif (!empty($file['owner_id'])) {
|
||||
$allowed = user_owns_owner($pdo, $user, (int) $file['owner_id']);
|
||||
$baseDir = $config['persondocs_dir'];
|
||||
}
|
||||
if (!$allowed) {
|
||||
json_error(403, 'No access to this file');
|
||||
}
|
||||
|
||||
$path = $baseDir . '/' . basename((string) $file['filename']);
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
$pdo->prepare('DELETE FROM doc_storage WHERE id = ?')->execute([$id]);
|
||||
|
||||
json_ok();
|
||||
@@ -1,110 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/document-file-replace.php",
|
||||
* tags={"Documents"},
|
||||
* summary="Replace the file of an existing document",
|
||||
* description="Atomic swap: the new file is written first, the row is repointed, then the old file is removed. Metadata (document_id, expiry, note, created_at) is kept and max_documents is not checked.",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\MediaType(mediaType="multipart/form-data",
|
||||
* @OA\Schema(
|
||||
* required={"id","file"},
|
||||
* @OA\Property(property="id", type="integer", description="doc_storage.id"),
|
||||
* @OA\Property(property="file", type="string", format="binary", description="PDF or image"),
|
||||
* @OA\Property(property="title", type="string", description="optional new display name")
|
||||
* )
|
||||
* )),
|
||||
* @OA\Response(response=200, description="Replaced", @OA\JsonContent(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/UploadedFile"))),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$config = require __DIR__ . '/config.php';
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
|
||||
$fields = [];
|
||||
if ($id <= 0) {
|
||||
$fields['id'] = ['Required'];
|
||||
}
|
||||
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
||||
$fields['file'] = ['Valid file is required'];
|
||||
}
|
||||
if ($fields) {
|
||||
json_error(422, 'Validation failed', $fields);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM doc_storage WHERE id = ? LIMIT 1');
|
||||
$stmt->execute([$id]);
|
||||
$file = $stmt->fetch();
|
||||
if (!$file) {
|
||||
json_error(404, 'File not found');
|
||||
}
|
||||
|
||||
$allowed = false;
|
||||
if (!empty($file['idhome'])) {
|
||||
$allowed = user_owns_home($pdo, $user, (int) $file['idhome']);
|
||||
} elseif (!empty($file['owner_id'])) {
|
||||
$allowed = user_owns_owner($pdo, $user, (int) $file['owner_id']);
|
||||
}
|
||||
if (!$allowed) {
|
||||
json_error(403, 'No access to this file');
|
||||
}
|
||||
|
||||
if (!is_allowed_upload($_FILES['file']['tmp_name'], $_FILES['file']['name'])) {
|
||||
json_error(422, 'Validation failed', ['file' => ['Only PDF or image files are allowed']]);
|
||||
}
|
||||
|
||||
$isPerson = ($file['entity_type'] ?? '') === 'person' || empty($file['idhome']);
|
||||
$dir = $isPerson ? $config['persondocs_dir'] : $config['homedocs_dir'];
|
||||
$prefix = $isPerson ? (int) $file['owner_id'] : (int) $file['idhome'];
|
||||
|
||||
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
json_error(500, 'Storage directory unavailable');
|
||||
}
|
||||
|
||||
$oldName = basename((string) $file['filename']);
|
||||
$oldPath = $dir . '/' . $oldName;
|
||||
|
||||
// Never reuse the current name: writing over it would destroy the original
|
||||
// before the row is repointed.
|
||||
$safe = preg_replace('/[^A-Za-z0-9._-]/', '_', basename($_FILES['file']['name']));
|
||||
$newName = $prefix . '-' . time() . '-' . $safe;
|
||||
while ($newName === $oldName || is_file($dir . '/' . $newName)) {
|
||||
$newName = $prefix . '-' . time() . '-' . bin2hex(random_bytes(3)) . '-' . $safe;
|
||||
}
|
||||
$newPath = $dir . '/' . $newName;
|
||||
|
||||
if (!move_uploaded_file($_FILES['file']['tmp_name'], $newPath)) {
|
||||
json_error(500, 'Failed to store file');
|
||||
}
|
||||
|
||||
// Repoint the row; on failure drop the new file so the old one stays authoritative.
|
||||
try {
|
||||
if (array_key_exists('title', $_POST)) {
|
||||
$title = $_POST['title'] !== '' ? $_POST['title'] : null;
|
||||
$pdo->prepare('UPDATE doc_storage SET filename = ?, title = ? WHERE id = ?')
|
||||
->execute([$newName, $title, $id]);
|
||||
} else {
|
||||
$pdo->prepare('UPDATE doc_storage SET filename = ? WHERE id = ?')->execute([$newName, $id]);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
@unlink($newPath);
|
||||
json_error(500, 'Failed to update document');
|
||||
}
|
||||
|
||||
if ($oldPath !== $newPath && is_file($oldPath)) {
|
||||
@unlink($oldPath);
|
||||
}
|
||||
|
||||
$row = $pdo->prepare('SELECT * FROM doc_storage WHERE id = ? LIMIT 1');
|
||||
$row->execute([$id]);
|
||||
|
||||
json_data(present_file($row->fetch()));
|
||||
@@ -1,94 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/document-file-update.php",
|
||||
* tags={"Documents"},
|
||||
* summary="Update an uploaded file's metadata",
|
||||
* description="Partial update: only the fields present in the body are changed.",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"id"},
|
||||
* @OA\Property(property="id", type="integer", description="doc_storage.id"),
|
||||
* @OA\Property(property="expiry_date", type="string", format="date", nullable=true),
|
||||
* @OA\Property(property="note", type="string", nullable=true),
|
||||
* @OA\Property(property="title", type="string", nullable=true,
|
||||
* description="display name; null resets it to the requirement name"),
|
||||
* @OA\Property(property="document_id", type="integer", description="move file to another requirement")
|
||||
* )),
|
||||
* @OA\Response(response=200, description="Updated", @OA\JsonContent(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/UploadedFile"))),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$in = body();
|
||||
$id = (int) ($in['id'] ?? 0);
|
||||
|
||||
if ($id <= 0) {
|
||||
json_error(422, 'id is required');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM doc_storage WHERE id = ? LIMIT 1');
|
||||
$stmt->execute([$id]);
|
||||
$file = $stmt->fetch();
|
||||
if (!$file) {
|
||||
json_error(404, 'File not found');
|
||||
}
|
||||
|
||||
$allowed = false;
|
||||
if (!empty($file['idhome'])) {
|
||||
$allowed = user_owns_home($pdo, $user, (int) $file['idhome']);
|
||||
} elseif (!empty($file['owner_id'])) {
|
||||
$allowed = user_owns_owner($pdo, $user, (int) $file['owner_id']);
|
||||
}
|
||||
if (!$allowed) {
|
||||
json_error(403, 'No access to this file');
|
||||
}
|
||||
|
||||
$data = [];
|
||||
|
||||
if (array_key_exists('expiry_date', $in)) {
|
||||
$expiry = $in['expiry_date'] !== '' ? $in['expiry_date'] : null;
|
||||
$data['expirydate'] = $expiry;
|
||||
$data['expirystatus'] = $expiry ? 1 : 0;
|
||||
}
|
||||
|
||||
if (array_key_exists('note', $in)) {
|
||||
$data['note'] = $in['note'] !== '' ? $in['note'] : null;
|
||||
}
|
||||
|
||||
// null or "" resets the display name back to the requirement's name.
|
||||
if (array_key_exists('title', $in)) {
|
||||
$data['title'] = ($in['title'] !== null && $in['title'] !== '') ? $in['title'] : null;
|
||||
}
|
||||
|
||||
if (array_key_exists('document_id', $in)) {
|
||||
$documentId = (int) $in['document_id'];
|
||||
if ($documentId <= 0) {
|
||||
json_error(422, 'Validation failed', ['document_id' => ['Must be a positive integer']]);
|
||||
}
|
||||
$exists = $pdo->prepare('SELECT 1 FROM documents WHERE document_id = ? LIMIT 1');
|
||||
$exists->execute([$documentId]);
|
||||
if (!$exists->fetchColumn()) {
|
||||
json_error(422, 'Validation failed', ['document_id' => ['Unknown document']]);
|
||||
}
|
||||
$data['document_id'] = $documentId;
|
||||
}
|
||||
|
||||
if (!$data) {
|
||||
json_error(422, 'Nothing to update');
|
||||
}
|
||||
|
||||
$set = implode(', ', array_map(fn ($c) => "$c = ?", array_keys($data)));
|
||||
$pdo->prepare("UPDATE doc_storage SET $set WHERE id = ?")
|
||||
->execute([...array_values($data), $id]);
|
||||
|
||||
$row = $pdo->prepare('SELECT * FROM doc_storage WHERE id = ? LIMIT 1');
|
||||
$row->execute([$id]);
|
||||
|
||||
json_data(present_file($row->fetch()));
|
||||
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/document-file.php",
|
||||
* tags={"Documents"},
|
||||
* summary="Download an uploaded file",
|
||||
* description="Access: home/owner owner or accepted share.",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Parameter(name="id", in="query", required=true, @OA\Schema(type="integer"),
|
||||
* description="doc_storage.id"),
|
||||
* @OA\Response(response=200, description="Binary file",
|
||||
* @OA\MediaType(mediaType="application/octet-stream",
|
||||
* @OA\Schema(type="string", format="binary"))),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
$config = require __DIR__ . '/config.php';
|
||||
$id = (int) query('id', 0);
|
||||
|
||||
if ($id <= 0) {
|
||||
json_error(422, 'id is required');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM doc_storage WHERE id = ? LIMIT 1');
|
||||
$stmt->execute([$id]);
|
||||
$file = $stmt->fetch();
|
||||
|
||||
if (!$file) {
|
||||
json_error(404, 'File not found');
|
||||
}
|
||||
|
||||
// Access check: home file or owner file.
|
||||
$allowed = false;
|
||||
if (!empty($file['idhome'])) {
|
||||
$allowed = user_can_access_home($pdo, $user, (int) $file['idhome']);
|
||||
$baseDir = $config['homedocs_dir'];
|
||||
} elseif (!empty($file['owner_id'])) {
|
||||
$allowed = user_owns_owner($pdo, $user, (int) $file['owner_id']);
|
||||
$baseDir = $config['persondocs_dir'];
|
||||
}
|
||||
|
||||
if (!$allowed) {
|
||||
json_error(403, 'No access to this file');
|
||||
}
|
||||
|
||||
$path = ($baseDir ?? '') . '/' . basename((string) $file['filename']);
|
||||
if (!is_file($path)) {
|
||||
json_error(404, 'File missing on disk');
|
||||
}
|
||||
|
||||
// Serve the binary file (override bootstrap JSON header).
|
||||
header('Content-Type: ' . (mime_content_type($path) ?: 'application/octet-stream'));
|
||||
header('Content-Disposition: attachment; filename="' . basename((string) $file['filename']) . '"');
|
||||
header('Content-Length: ' . filesize($path));
|
||||
readfile($path);
|
||||
exit;
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/document-templates.php",
|
||||
* tags={"Reference"},
|
||||
* summary="Document templates reference",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Parameter(name="slug", in="query", @OA\Schema(type="string"), description="filter by pages.slug"),
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/DocumentTemplate"))
|
||||
* )),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
require_auth($pdo);
|
||||
$slug = query('slug');
|
||||
|
||||
$sql = 'SELECT d.*, s.section_name
|
||||
FROM documents d
|
||||
LEFT JOIN sections s ON s.idsections = d.idsections';
|
||||
$params = [];
|
||||
if ($slug !== null && $slug !== '') {
|
||||
$sql .= ' JOIN pages p ON p.idpages = d.page_id WHERE p.slug = ?';
|
||||
$params[] = $slug;
|
||||
}
|
||||
$sql .= ' ORDER BY s.section_name, d.document_name';
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
json_data(array_map('present_document_template', $stmt->fetchAll()));
|
||||
@@ -1,78 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/document-upload.php",
|
||||
* tags={"Documents"},
|
||||
* summary="Upload a document file for a home",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\MediaType(mediaType="multipart/form-data",
|
||||
* @OA\Schema(
|
||||
* required={"idhome","document_id","file"},
|
||||
* @OA\Property(property="idhome", type="integer"),
|
||||
* @OA\Property(property="document_id", type="integer"),
|
||||
* @OA\Property(property="file", type="string", format="binary", description="PDF or image"),
|
||||
* @OA\Property(property="expiry_date", type="string", format="date"),
|
||||
* @OA\Property(property="note", type="string"),
|
||||
* @OA\Property(property="title", type="string", description="optional display name")
|
||||
* )
|
||||
* )),
|
||||
* @OA\Response(response=201, description="File uploaded", @OA\JsonContent(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/UploadedFile"))),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$config = require __DIR__ . '/config.php';
|
||||
|
||||
$idhome = (int) ($_POST['idhome'] ?? 0);
|
||||
$documentId = (int) ($_POST['document_id'] ?? 0);
|
||||
$expiry = $_POST['expiry_date'] ?? null;
|
||||
$note = $_POST['note'] ?? null;
|
||||
$title = $_POST['title'] ?? null;
|
||||
|
||||
$fields = [];
|
||||
if ($idhome <= 0) { $fields['idhome'] = ['Required']; }
|
||||
if ($documentId <= 0) { $fields['document_id'] = ['Required']; }
|
||||
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
||||
$fields['file'] = ['Valid file is required'];
|
||||
} elseif (!is_allowed_upload($_FILES['file']['tmp_name'], $_FILES['file']['name'])) {
|
||||
$fields['file'] = ['Only PDF or image files are allowed'];
|
||||
}
|
||||
if ($fields) {
|
||||
json_error(422, 'Validation failed', $fields);
|
||||
}
|
||||
|
||||
// Owner only (not shared access) may upload.
|
||||
if (!user_owns_home($pdo, $user, $idhome)) {
|
||||
json_error(403, 'No access to this home');
|
||||
}
|
||||
|
||||
$dir = $config['homedocs_dir'];
|
||||
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
json_error(500, 'Storage directory unavailable');
|
||||
}
|
||||
|
||||
$safe = preg_replace('/[^A-Za-z0-9._-]/', '_', basename($_FILES['file']['name']));
|
||||
$filename = $idhome . '-' . time() . '-' . $safe;
|
||||
|
||||
if (!move_uploaded_file($_FILES['file']['tmp_name'], $dir . '/' . $filename)) {
|
||||
json_error(500, 'Failed to store file');
|
||||
}
|
||||
|
||||
$expiryStatus = $expiry ? 1 : 0;
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT INTO doc_storage (idhome, entity_type, document_id, title, filename, expirystatus, expirydate, note, created_at, updated_at)
|
||||
VALUES (?, 'home', ?, ?, ?, ?, ?, ?, NOW(), NOW())"
|
||||
);
|
||||
$stmt->execute([$idhome, $documentId, $title ?: null, $filename, $expiryStatus, $expiry ?: null, $note ?: null]);
|
||||
$id = (int) $pdo->lastInsertId();
|
||||
|
||||
$row = $pdo->prepare('SELECT * FROM doc_storage WHERE id = ?');
|
||||
$row->execute([$id]);
|
||||
|
||||
json_data(present_file($row->fetch()), 201);
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/documents-download.php",
|
||||
* tags={"Documents"},
|
||||
* summary="ZIP of uploaded home documents",
|
||||
* description="Owner gets all files; share recipient gets only allowed sections.",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Parameter(name="idhome", in="query", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Response(response=200, description="ZIP",
|
||||
* @OA\MediaType(mediaType="application/zip", @OA\Schema(type="string", format="binary"))),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
$config = require __DIR__ . '/config.php';
|
||||
$idhome = (int) query('idhome', 0);
|
||||
|
||||
if ($idhome <= 0) {
|
||||
json_error(422, 'idhome is required');
|
||||
}
|
||||
if (!user_can_access_home($pdo, $user, $idhome)) {
|
||||
json_error(403, 'No access to this home');
|
||||
}
|
||||
if (!class_exists('ZipArchive')) {
|
||||
json_error(500, 'ZipArchive extension not available');
|
||||
}
|
||||
|
||||
$isOwner = user_owns_home($pdo, $user, $idhome);
|
||||
|
||||
// Share recipient: restrict to allowed sections.
|
||||
$allowedSections = null;
|
||||
if (!$isOwner) {
|
||||
$shareStmt = $pdo->prepare(
|
||||
"SELECT shared_sections FROM home_sharing
|
||||
WHERE idhome = ? AND (idshareduser = ? OR shared_email = ?) AND status = 'accepted'
|
||||
LIMIT 1"
|
||||
);
|
||||
$shareStmt->execute([$idhome, $user['id'], $user['email']]);
|
||||
$decoded = json_decode((string) $shareStmt->fetchColumn(), true);
|
||||
$allowedSections = is_array($decoded) ? array_map('intval', $decoded) : [];
|
||||
}
|
||||
|
||||
$sql = 'SELECT ds.filename, d.document_name, s.section_name, d.idsections
|
||||
FROM doc_storage ds
|
||||
JOIN documents d ON d.document_id = ds.document_id
|
||||
LEFT JOIN sections s ON s.idsections = d.idsections
|
||||
WHERE ds.idhome = ?';
|
||||
$params = [$idhome];
|
||||
if ($allowedSections !== null) {
|
||||
if (!$allowedSections) {
|
||||
json_error(403, 'No shared sections');
|
||||
}
|
||||
$in = implode(',', array_fill(0, count($allowedSections), '?'));
|
||||
$sql .= " AND d.idsections IN ($in)";
|
||||
$params = array_merge($params, $allowedSections);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
$zipPath = tempnam(sys_get_temp_dir(), 'casadoc_') . '.zip';
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
json_error(500, 'Cannot create archive');
|
||||
}
|
||||
foreach ($rows as $r) {
|
||||
$path = $config['homedocs_dir'] . '/' . basename((string) $r['filename']);
|
||||
if (is_file($path)) {
|
||||
$folder = ($r['section_name'] ?: 'Senza sezione') . '/' . ($r['document_name'] ?: 'documento');
|
||||
$zip->addFile($path, $folder . '/' . basename((string) $r['filename']));
|
||||
}
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
header('Content-Type: application/zip');
|
||||
header('Content-Disposition: attachment; filename="Documenti_' . $idhome . '.zip"');
|
||||
header('Content-Length: ' . filesize($zipPath));
|
||||
readfile($zipPath);
|
||||
@unlink($zipPath);
|
||||
exit;
|
||||
@@ -1,97 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/documents.php",
|
||||
* tags={"Documents"},
|
||||
* summary="Home document requirements with uploaded files",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Parameter(name="idhome", in="query", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Parameter(name="slug", in="query", @OA\Schema(type="string", default="legal")),
|
||||
* @OA\Parameter(name="section_id", in="query", @OA\Schema(type="integer")),
|
||||
* @OA\Parameter(name="only_required", in="query", @OA\Schema(type="boolean")),
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="home", ref="#/components/schemas/Home"),
|
||||
* @OA\Property(property="data", type="array",
|
||||
* @OA\Items(ref="#/components/schemas/DocumentRequirement"))
|
||||
* )),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
$idhome = (int) query('idhome', 0);
|
||||
$slug = (string) query('slug', 'legal');
|
||||
|
||||
if ($idhome <= 0) {
|
||||
json_error(422, 'idhome is required');
|
||||
}
|
||||
if (!user_can_access_home($pdo, $user, $idhome)) {
|
||||
json_error(403, 'No access to this home');
|
||||
}
|
||||
|
||||
$homeStmt = $pdo->prepare('SELECT * FROM home WHERE idhome = ? LIMIT 1');
|
||||
$homeStmt->execute([$idhome]);
|
||||
$home = $homeStmt->fetch();
|
||||
if (!$home) {
|
||||
json_error(404, 'Home not found');
|
||||
}
|
||||
|
||||
// Category page by slug.
|
||||
$pageStmt = $pdo->prepare('SELECT idpages FROM pages WHERE slug = ? LIMIT 1');
|
||||
$pageStmt->execute([$slug]);
|
||||
$pageId = $pageStmt->fetchColumn();
|
||||
if ($pageId === false) {
|
||||
json_error(404, 'Page not found');
|
||||
}
|
||||
|
||||
// Requirements with section.
|
||||
$sql = 'SELECT d.*, s.section_name
|
||||
FROM documents d
|
||||
LEFT JOIN sections s ON s.idsections = d.idsections
|
||||
WHERE d.page_id = ?';
|
||||
$params = [$pageId];
|
||||
|
||||
if (query('only_required') === 'true' || query('only_required') === '1') {
|
||||
$sql .= ' AND d.is_required = 1';
|
||||
}
|
||||
if (($sectionId = (int) query('section_id', 0)) > 0) {
|
||||
$sql .= ' AND d.idsections = ?';
|
||||
$params[] = $sectionId;
|
||||
}
|
||||
$sql .= ' ORDER BY s.section_name, d.document_name';
|
||||
|
||||
$docStmt = $pdo->prepare($sql);
|
||||
$docStmt->execute($params);
|
||||
$documents = $docStmt->fetchAll();
|
||||
|
||||
// Uploaded files for this home, grouped by document_id.
|
||||
$filesStmt = $pdo->prepare('SELECT * FROM doc_storage WHERE idhome = ?');
|
||||
$filesStmt->execute([$idhome]);
|
||||
$filesByDoc = [];
|
||||
foreach ($filesStmt->fetchAll() as $f) {
|
||||
$filesByDoc[(int) $f['document_id']][] = present_file($f);
|
||||
}
|
||||
|
||||
$result = array_map(function ($d) use ($filesByDoc) {
|
||||
$id = (int) $d['document_id'];
|
||||
return [
|
||||
'document_id' => $id,
|
||||
'document_name' => $d['document_name'],
|
||||
'page_id' => isset($d['page_id']) ? (int) $d['page_id'] : null,
|
||||
'idsections' => isset($d['idsections']) ? (int) $d['idsections'] : null,
|
||||
'section_name' => $d['section_name'] ?? null,
|
||||
'max_documents' => (int) ($d['max_documents'] ?? 0),
|
||||
'is_required' => (bool) ($d['is_required'] ?? 0),
|
||||
'notes' => $d['notes'] ?? null,
|
||||
'files' => $filesByDoc[$id] ?? [],
|
||||
];
|
||||
}, $documents);
|
||||
|
||||
http_response_code(200);
|
||||
echo json_encode([
|
||||
'home' => present_home($home, (int) $home['iduser'] === (int) $user['id'],
|
||||
home_counts($pdo, [$idhome])[$idhome] ?? null),
|
||||
'data' => $result,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
require __DIR__ . '/_codes.php';
|
||||
require __DIR__ . '/_mail.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/email-verify-resend.php",
|
||||
* tags={"Auth"},
|
||||
* summary="Re-send the e-mail verification code",
|
||||
* description="Always answers 200 so the endpoint cannot be used to probe which e-mails are registered. Silently ignored more often than once per 60 seconds.",
|
||||
* security={},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"email"},
|
||||
* @OA\Property(property="email", type="string", format="email")
|
||||
* )),
|
||||
* @OA\Response(response=200, ref="#/components/responses/Success")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
|
||||
$email = trim((string) (body()['email'] ?? ''));
|
||||
|
||||
if ($email !== '') {
|
||||
$stmt = $pdo->prepare('SELECT id FROM auth_users WHERE email = ? AND email_verified_at IS NULL LIMIT 1');
|
||||
$stmt->execute([$email]);
|
||||
if ($stmt->fetchColumn() && $code = issue_code($pdo, 'auth_email_verifications', $email)) {
|
||||
send_code_mail($email, $code);
|
||||
}
|
||||
}
|
||||
|
||||
json_ok();
|
||||
@@ -1,59 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
require __DIR__ . '/_codes.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/email-verify.php",
|
||||
* tags={"Auth"},
|
||||
* summary="Confirm the e-mail with a 6-digit code and log in",
|
||||
* description="On success a bearer token is issued, so the app enters without a second login. Error codes: code_invalid (retry) or code_expired (request a new code).",
|
||||
* security={},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"email","code","device_name"},
|
||||
* @OA\Property(property="email", type="string", format="email"),
|
||||
* @OA\Property(property="code", type="string", example="123456"),
|
||||
* @OA\Property(property="device_name", type="string", example="iPhone 15")
|
||||
* )),
|
||||
* @OA\Response(response=200, description="Verified", @OA\JsonContent(
|
||||
* @OA\Property(property="data", type="object",
|
||||
* @OA\Property(property="token", type="string"),
|
||||
* @OA\Property(property="user", ref="#/components/schemas/User")
|
||||
* )
|
||||
* )),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
|
||||
$in = body();
|
||||
$email = trim((string) ($in['email'] ?? ''));
|
||||
$code = trim((string) ($in['code'] ?? ''));
|
||||
$device = trim((string) ($in['device_name'] ?? ''));
|
||||
|
||||
$fields = [];
|
||||
if ($email === '') { $fields['email'] = ['Required']; }
|
||||
if ($code === '') { $fields['code'] = ['Required']; }
|
||||
if ($device === '') { $fields['device_name'] = ['Required']; }
|
||||
if ($fields) {
|
||||
json_error(422, 'Validation failed', $fields);
|
||||
}
|
||||
|
||||
$result = check_code($pdo, 'auth_email_verifications', $email, $code);
|
||||
if ($result !== 'ok') {
|
||||
json_error(422, $result === 'code_expired' ? 'Code expired, request a new one' : 'Invalid code', null, $result);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM auth_users WHERE email = ? LIMIT 1');
|
||||
$stmt->execute([$email]);
|
||||
$user = $stmt->fetch();
|
||||
if (!$user) {
|
||||
json_error(422, 'Invalid code', null, 'code_invalid');
|
||||
}
|
||||
|
||||
$pdo->prepare('UPDATE auth_users SET email_verified_at = NOW() WHERE id = ?')->execute([$user['id']]);
|
||||
|
||||
json_data([
|
||||
'token' => issue_token($pdo, (int) $user['id'], $device),
|
||||
'user' => present_user($user),
|
||||
]);
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/home-delete.php",
|
||||
* tags={"Homes"},
|
||||
* summary="Delete property (cascade files and sharing)",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"idhome"},
|
||||
* @OA\Property(property="idhome", type="integer")
|
||||
* )),
|
||||
* @OA\Response(response=200, ref="#/components/responses/Success"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$idhome = (int) (body()['idhome'] ?? 0);
|
||||
|
||||
if ($idhome <= 0) {
|
||||
json_error(422, 'idhome is required');
|
||||
}
|
||||
if (!user_owns_home($pdo, $user, $idhome)) {
|
||||
json_error(403, 'No access to this home');
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$pdo->prepare('DELETE FROM doc_storage WHERE idhome = ?')->execute([$idhome]);
|
||||
$pdo->prepare('DELETE FROM home_sharing WHERE idhome = ?')->execute([$idhome]);
|
||||
$pdo->prepare('DELETE FROM home_owners WHERE home_id = ?')->execute([$idhome]);
|
||||
$pdo->prepare('DELETE FROM home WHERE idhome = ? AND iduser = ?')->execute([$idhome, $user['id']]);
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
json_error(500, 'Delete failed');
|
||||
}
|
||||
|
||||
json_ok();
|
||||
@@ -1,63 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/home-owner-attach.php",
|
||||
* tags={"Owners"},
|
||||
* summary="Attach owner to home with share",
|
||||
* description="Requires ownership of both home and owner. Ensures total share ≤ 100%.",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"idhome","owner_id","ownership_percentage"},
|
||||
* @OA\Property(property="idhome", type="integer"),
|
||||
* @OA\Property(property="owner_id", type="integer"),
|
||||
* @OA\Property(property="ownership_percentage", type="number", format="float"),
|
||||
* @OA\Property(property="notes", type="string")
|
||||
* )),
|
||||
* @OA\Response(response=200, ref="#/components/responses/Success"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$in = body();
|
||||
|
||||
$idhome = (int) ($in['idhome'] ?? 0);
|
||||
$ownerId = (int) ($in['owner_id'] ?? 0);
|
||||
$share = $in['ownership_percentage'] ?? null;
|
||||
$notes = $in['notes'] ?? null;
|
||||
|
||||
$fields = [];
|
||||
if ($idhome <= 0) { $fields['idhome'] = ['Required']; }
|
||||
if ($ownerId <= 0) { $fields['owner_id'] = ['Required']; }
|
||||
if ($share === null || !is_numeric($share)) { $fields['ownership_percentage'] = ['Required numeric']; }
|
||||
if ($fields) {
|
||||
json_error(422, 'Validation failed', $fields);
|
||||
}
|
||||
|
||||
if (!user_owns_home($pdo, $user, $idhome) || !user_owns_owner($pdo, $user, $ownerId)) {
|
||||
json_error(403, 'No access to this home or owner');
|
||||
}
|
||||
|
||||
// Already attached?
|
||||
$exists = $pdo->prepare('SELECT 1 FROM home_owners WHERE home_id = ? AND owner_id = ? LIMIT 1');
|
||||
$exists->execute([$idhome, $ownerId]);
|
||||
if ($exists->fetchColumn()) {
|
||||
json_error(422, 'Owner already attached', ['owner_id' => ['Already attached']]);
|
||||
}
|
||||
|
||||
// Total share ≤ 100.
|
||||
$sumStmt = $pdo->prepare('SELECT COALESCE(SUM(ownership_percentage), 0) FROM home_owners WHERE home_id = ?');
|
||||
$sumStmt->execute([$idhome]);
|
||||
if ((float) $sumStmt->fetchColumn() + (float) $share > 100.0) {
|
||||
json_error(422, 'Total ownership exceeds 100%', ['ownership_percentage' => ['Sum exceeds 100']]);
|
||||
}
|
||||
|
||||
$pdo->prepare(
|
||||
'INSERT INTO home_owners (home_id, owner_id, ownership_percentage, notes, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, NOW(), NOW())'
|
||||
)->execute([$idhome, $ownerId, (float) $share, $notes ?: null]);
|
||||
|
||||
json_ok();
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/home-owner-detach.php",
|
||||
* tags={"Owners"},
|
||||
* summary="Detach owner from home",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"idhome","owner_id"},
|
||||
* @OA\Property(property="idhome", type="integer"),
|
||||
* @OA\Property(property="owner_id", type="integer")
|
||||
* )),
|
||||
* @OA\Response(response=200, ref="#/components/responses/Success"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$in = body();
|
||||
|
||||
$idhome = (int) ($in['idhome'] ?? 0);
|
||||
$ownerId = (int) ($in['owner_id'] ?? 0);
|
||||
|
||||
if ($idhome <= 0 || $ownerId <= 0) {
|
||||
json_error(422, 'idhome and owner_id are required');
|
||||
}
|
||||
if (!user_owns_home($pdo, $user, $idhome)) {
|
||||
json_error(403, 'No access to this home');
|
||||
}
|
||||
|
||||
$pdo->prepare('DELETE FROM home_owners WHERE home_id = ? AND owner_id = ?')
|
||||
->execute([$idhome, $ownerId]);
|
||||
|
||||
json_ok();
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/home-owners.php",
|
||||
* tags={"Owners"},
|
||||
* summary="Home owners with shares",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Parameter(name="idhome", in="query", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/HomeOwner"))
|
||||
* )),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
$idhome = (int) query('idhome', 0);
|
||||
|
||||
if ($idhome <= 0) {
|
||||
json_error(422, 'idhome is required');
|
||||
}
|
||||
if (!user_can_access_home($pdo, $user, $idhome)) {
|
||||
json_error(403, 'No access to this home');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT po.*, ho.ownership_percentage, ho.notes
|
||||
FROM home_owners ho
|
||||
JOIN property_owners po ON po.owner_id = ho.owner_id
|
||||
WHERE ho.home_id = ?
|
||||
ORDER BY po.last_name, po.company_name'
|
||||
);
|
||||
$stmt->execute([$idhome]);
|
||||
|
||||
json_data(array_map('present_home_owner', $stmt->fetchAll()));
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/home-photo.php",
|
||||
* tags={"Homes"},
|
||||
* summary="Upload property main photo",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\MediaType(mediaType="multipart/form-data",
|
||||
* @OA\Schema(
|
||||
* required={"idhome","photo"},
|
||||
* @OA\Property(property="idhome", type="integer"),
|
||||
* @OA\Property(property="photo", type="string", format="binary", description="image")
|
||||
* )
|
||||
* )),
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Home"))),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$config = require __DIR__ . '/config.php';
|
||||
|
||||
$idhome = (int) ($_POST['idhome'] ?? 0);
|
||||
$fields = [];
|
||||
if ($idhome <= 0) {
|
||||
$fields['idhome'] = ['Required'];
|
||||
}
|
||||
if (empty($_FILES['photo']) || $_FILES['photo']['error'] !== UPLOAD_ERR_OK) {
|
||||
$fields['photo'] = ['Valid image is required'];
|
||||
} elseif (!is_allowed_upload($_FILES['photo']['tmp_name'], $_FILES['photo']['name'], true)) {
|
||||
$fields['photo'] = ['Only image files are allowed'];
|
||||
}
|
||||
if ($fields) {
|
||||
json_error(422, 'Validation failed', $fields);
|
||||
}
|
||||
if (!user_owns_home($pdo, $user, $idhome)) {
|
||||
json_error(403, 'No access to this home');
|
||||
}
|
||||
|
||||
$dir = $config['mainphoto_dir'];
|
||||
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
json_error(500, 'Storage directory unavailable');
|
||||
}
|
||||
|
||||
$safe = preg_replace('/[^A-Za-z0-9._-]/', '_', basename($_FILES['photo']['name']));
|
||||
$filename = $idhome . '-' . $user['id'] . '-' . time() . '-' . $safe;
|
||||
|
||||
if (!move_uploaded_file($_FILES['photo']['tmp_name'], $dir . '/' . $filename)) {
|
||||
json_error(500, 'Failed to store photo');
|
||||
}
|
||||
|
||||
$pdo->prepare('UPDATE home SET mainphoto = ? WHERE idhome = ? AND iduser = ?')
|
||||
->execute([$filename, $idhome, $user['id']]);
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM home WHERE idhome = ? LIMIT 1');
|
||||
$stmt->execute([$idhome]);
|
||||
|
||||
json_data(present_home($stmt->fetch(), true, home_counts($pdo, [$idhome])[$idhome] ?? null));
|
||||
@@ -1,72 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/home-report.php",
|
||||
* tags={"Homes"},
|
||||
* summary="Property PDF report",
|
||||
* description="Access: owner or accepted share. Requires TCPDF library (composer).",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Parameter(name="idhome", in="query", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Response(response=200, description="PDF",
|
||||
* @OA\MediaType(mediaType="application/pdf", @OA\Schema(type="string", format="binary"))),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
$idhome = (int) query('idhome', 0);
|
||||
|
||||
if ($idhome <= 0) {
|
||||
json_error(422, 'idhome is required');
|
||||
}
|
||||
if (!user_can_access_home($pdo, $user, $idhome)) {
|
||||
json_error(403, 'No access to this home');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM home WHERE idhome = ? LIMIT 1');
|
||||
$stmt->execute([$idhome]);
|
||||
$home = $stmt->fetch();
|
||||
if (!$home) {
|
||||
json_error(404, 'Home not found');
|
||||
}
|
||||
|
||||
// TCPDF is provided by the main app composer autoload.
|
||||
$autoload = __DIR__ . '/../../../vendor/autoload.php';
|
||||
if (is_file($autoload)) {
|
||||
require_once $autoload;
|
||||
}
|
||||
if (!class_exists('TCPDF')) {
|
||||
json_error(501, 'PDF library (TCPDF) not installed');
|
||||
}
|
||||
|
||||
$pdf = new TCPDF();
|
||||
$pdf->SetCreator('Casadoc');
|
||||
$pdf->SetTitle('Report immobile');
|
||||
$pdf->AddPage();
|
||||
$pdf->SetFont('helvetica', 'B', 16);
|
||||
$pdf->Cell(0, 10, (string) ($home['name'] ?? 'Immobile'), 0, 1);
|
||||
$pdf->SetFont('helvetica', '', 11);
|
||||
|
||||
$rows = [
|
||||
'Indirizzo' => trim(($home['address'] ?? '') . ', ' . ($home['zip'] ?? '') . ' ' . ($home['city'] ?? '')),
|
||||
'Comune cat.'=> $home['cadastral_municipality'] ?? '',
|
||||
'Foglio' => $home['cadastral_sheet'] ?? '',
|
||||
'Particella' => $home['cadastral_particle'] ?? '',
|
||||
'Categoria' => $home['cadastral_category'] ?? '',
|
||||
'Superficie' => $home['cadastral_surface'] ?? '',
|
||||
'Rendita' => $home['cadastral_rendita'] ?? '',
|
||||
];
|
||||
foreach ($rows as $label => $value) {
|
||||
$pdf->Cell(45, 8, $label . ':', 0, 0);
|
||||
$pdf->Cell(0, 8, (string) $value, 0, 1);
|
||||
}
|
||||
|
||||
$body = $pdf->Output('report.pdf', 'S');
|
||||
|
||||
header('Content-Type: application/pdf');
|
||||
header('Content-Disposition: attachment; filename="Report_' . $idhome . '.pdf"');
|
||||
header('Content-Length: ' . strlen($body));
|
||||
echo $body;
|
||||
exit;
|
||||
@@ -1,87 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/home-save.php",
|
||||
* tags={"Homes"},
|
||||
* summary="Create or update property",
|
||||
* description="idhome missing/0 creates; otherwise updates own property.",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* @OA\Property(property="idhome", type="integer"),
|
||||
* @OA\Property(property="name", type="string"),
|
||||
* @OA\Property(property="comment", type="string"),
|
||||
* @OA\Property(property="fulladdress", type="string"),
|
||||
* @OA\Property(property="address", type="string"),
|
||||
* @OA\Property(property="zip", type="string"),
|
||||
* @OA\Property(property="city", type="string"),
|
||||
* @OA\Property(property="country", type="string"),
|
||||
* @OA\Property(property="latitude", type="string"),
|
||||
* @OA\Property(property="longitude", type="string"),
|
||||
* @OA\Property(property="cadastral_municipality", type="string"),
|
||||
* @OA\Property(property="cadastral_section", type="string"),
|
||||
* @OA\Property(property="cadastral_sheet", type="string"),
|
||||
* @OA\Property(property="cadastral_particle", type="string"),
|
||||
* @OA\Property(property="cadastral_sub", type="string"),
|
||||
* @OA\Property(property="cadastral_category", type="string"),
|
||||
* @OA\Property(property="cadastral_class", type="string"),
|
||||
* @OA\Property(property="cadastral_surface", type="string"),
|
||||
* @OA\Property(property="cadastral_rendita", type="string"),
|
||||
* @OA\Property(property="cadastral_notes", type="string")
|
||||
* )),
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Home"))),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$in = body();
|
||||
|
||||
$allowed = [
|
||||
'name', 'comment', 'fulladdress', 'address', 'zip', 'city', 'country',
|
||||
'latitude', 'longitude', 'cadastral_municipality', 'cadastral_section',
|
||||
'cadastral_sheet', 'cadastral_particle', 'cadastral_sub', 'cadastral_category',
|
||||
'cadastral_class', 'cadastral_surface', 'cadastral_rendita', 'cadastral_notes',
|
||||
];
|
||||
|
||||
$data = [];
|
||||
foreach ($allowed as $col) {
|
||||
if (array_key_exists($col, $in)) {
|
||||
$data[$col] = $in[$col];
|
||||
}
|
||||
}
|
||||
|
||||
$idhome = (int) ($in['idhome'] ?? 0);
|
||||
|
||||
if ($idhome > 0) {
|
||||
// Update: own property only.
|
||||
if (!user_owns_home($pdo, $user, $idhome)) {
|
||||
json_error(403, 'No access to this home');
|
||||
}
|
||||
if ($data) {
|
||||
$set = implode(', ', array_map(fn ($c) => "$c = ?", array_keys($data)));
|
||||
$stmt = $pdo->prepare("UPDATE home SET $set WHERE idhome = ? AND iduser = ?");
|
||||
$stmt->execute([...array_values($data), $idhome, $user['id']]);
|
||||
}
|
||||
} else {
|
||||
// Create.
|
||||
// These columns are NOT NULL without a default in the legacy schema.
|
||||
foreach (['name', 'address', 'zip', 'city', 'country', 'cadastral_municipality',
|
||||
'latitude', 'longitude', 'fulladdress'] as $col) {
|
||||
$data[$col] ??= '';
|
||||
}
|
||||
$data['iduser'] = $user['id'];
|
||||
$cols = implode(', ', array_keys($data));
|
||||
$ph = implode(', ', array_fill(0, count($data), '?'));
|
||||
$stmt = $pdo->prepare("INSERT INTO home ($cols) VALUES ($ph)");
|
||||
$stmt->execute(array_values($data));
|
||||
$idhome = (int) $pdo->lastInsertId();
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM home WHERE idhome = ? LIMIT 1');
|
||||
$stmt->execute([$idhome]);
|
||||
|
||||
json_data(present_home($stmt->fetch(), true, home_counts($pdo, [$idhome])[$idhome] ?? null));
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/home.php",
|
||||
* tags={"Homes"},
|
||||
* summary="Property data",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Parameter(name="idhome", in="query", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Home")
|
||||
* )),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
$idhome = (int) query('idhome', 0);
|
||||
|
||||
if ($idhome <= 0) {
|
||||
json_error(422, 'idhome is required');
|
||||
}
|
||||
if (!user_can_access_home($pdo, $user, $idhome)) {
|
||||
json_error(403, 'No access to this home');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM home WHERE idhome = ? LIMIT 1');
|
||||
$stmt->execute([$idhome]);
|
||||
$home = $stmt->fetch();
|
||||
|
||||
if (!$home) {
|
||||
json_error(404, 'Home not found');
|
||||
}
|
||||
|
||||
$counts = home_counts($pdo, [$idhome]);
|
||||
|
||||
json_data(present_home($home, (int) $home['iduser'] === (int) $user['id'], $counts[$idhome] ?? null));
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/homes-shared.php",
|
||||
* tags={"Sharing"},
|
||||
* summary="Homes shared with me (status=accepted)",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Home"))
|
||||
* )),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT h.* FROM home_sharing hs
|
||||
JOIN home h ON h.idhome = hs.idhome
|
||||
WHERE (hs.idshareduser = ? OR hs.shared_email = ?)
|
||||
AND hs.status = 'accepted'
|
||||
AND (hs.expiration_date IS NULL OR hs.expiration_date >= CURDATE())
|
||||
GROUP BY h.idhome
|
||||
ORDER BY h.idhome DESC"
|
||||
);
|
||||
$stmt->execute([$user['id'], $user['email']]);
|
||||
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
$counts = home_counts($pdo, array_column($rows, 'idhome'));
|
||||
$homes = array_map(fn ($h) => present_home($h, false, $counts[(int) $h['idhome']] ?? null), $rows);
|
||||
|
||||
json_data($homes);
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/homes.php",
|
||||
* tags={"Homes"},
|
||||
* summary="My properties",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Home"))
|
||||
* )),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM home WHERE iduser = ? ORDER BY idhome DESC');
|
||||
$stmt->execute([$user['id']]);
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
$counts = home_counts($pdo, array_column($rows, 'idhome'));
|
||||
$homes = array_map(fn ($h) => present_home($h, true, $counts[(int) $h['idhome']] ?? null), $rows);
|
||||
|
||||
json_data($homes);
|
||||
@@ -1,68 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/login.php",
|
||||
* tags={"Auth"},
|
||||
* summary="Log in, issue bearer token",
|
||||
* security={},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"login","password","device_name"},
|
||||
* @OA\Property(property="login", type="string", description="e-mail or username"),
|
||||
* @OA\Property(property="password", type="string", format="password"),
|
||||
* @OA\Property(property="device_name", type="string", example="iPhone 15")
|
||||
* )),
|
||||
* @OA\Response(response=200, description="Success", @OA\JsonContent(
|
||||
* @OA\Property(property="data", type="object",
|
||||
* @OA\Property(property="token", type="string"),
|
||||
* @OA\Property(property="user", ref="#/components/schemas/User")
|
||||
* )
|
||||
* )),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, description="E-mail not verified (code email_not_verified)",
|
||||
* @OA\JsonContent(ref="#/components/schemas/Error")),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
|
||||
$in = body();
|
||||
$login = trim((string) ($in['login'] ?? ''));
|
||||
$pass = (string) ($in['password'] ?? '');
|
||||
$device = trim((string) ($in['device_name'] ?? ''));
|
||||
|
||||
$fields = [];
|
||||
if ($login === '') { $fields['login'] = ['Required']; }
|
||||
if ($pass === '') { $fields['password'] = ['Required']; }
|
||||
if ($device === '') { $fields['device_name'] = ['Required']; }
|
||||
if ($fields) {
|
||||
json_error(422, 'Validation failed', $fields);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM auth_users WHERE email = ? OR username = ? LIMIT 1');
|
||||
$stmt->execute([$login, $login]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if (!$user || !password_verify($pass, (string) $user['password'])) {
|
||||
json_error(401, 'Invalid credentials');
|
||||
}
|
||||
|
||||
// Distinct code so the app opens the verification screen instead of blaming the password.
|
||||
if (empty($user['email_verified_at'])) {
|
||||
json_error(403, 'E-mail is not verified', null, 'email_not_verified');
|
||||
}
|
||||
|
||||
$plain = bin2hex(random_bytes(32));
|
||||
$config = require __DIR__ . '/config.php';
|
||||
$expires = (new DateTimeImmutable("+{$config['token_ttl_days']} days"))->format('Y-m-d H:i:s');
|
||||
|
||||
$pdo->prepare(
|
||||
'INSERT INTO api_tokens (user_id, name, token, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, NOW())'
|
||||
)->execute([$user['id'], $device, hash('sha256', $plain), $expires]);
|
||||
|
||||
json_data([
|
||||
'token' => $plain,
|
||||
'user' => present_user($user),
|
||||
]);
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/logout.php",
|
||||
* tags={"Auth"},
|
||||
* summary="Revoke current token",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Response(response=200, ref="#/components/responses/Success"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
require_auth($pdo);
|
||||
|
||||
$token = bearer_token();
|
||||
$pdo->prepare('DELETE FROM api_tokens WHERE token = ?')->execute([hash('sha256', (string) $token)]);
|
||||
|
||||
json_ok();
|
||||
@@ -1,57 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/me-avatar.php",
|
||||
* tags={"Auth"},
|
||||
* summary="Upload the current user's avatar",
|
||||
* description="Stored under public/upload/users; the User.avatar field is returned as an absolute URL.",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\MediaType(mediaType="multipart/form-data",
|
||||
* @OA\Schema(
|
||||
* required={"avatar"},
|
||||
* @OA\Property(property="avatar", type="string", format="binary", description="image")
|
||||
* )
|
||||
* )),
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/User"))),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$config = require __DIR__ . '/config.php';
|
||||
|
||||
if (empty($_FILES['avatar']) || $_FILES['avatar']['error'] !== UPLOAD_ERR_OK) {
|
||||
json_error(422, 'Validation failed', ['avatar' => ['Valid image is required']]);
|
||||
}
|
||||
if (!is_allowed_upload($_FILES['avatar']['tmp_name'], $_FILES['avatar']['name'], true)) {
|
||||
json_error(422, 'Validation failed', ['avatar' => ['Only image files are allowed']]);
|
||||
}
|
||||
|
||||
$dir = $config['avatars_dir'];
|
||||
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
json_error(500, 'Storage directory unavailable');
|
||||
}
|
||||
|
||||
$safe = preg_replace('/[^A-Za-z0-9._-]/', '_', basename($_FILES['avatar']['name']));
|
||||
$filename = $user['id'] . '-' . time() . '-' . $safe;
|
||||
|
||||
if (!move_uploaded_file($_FILES['avatar']['tmp_name'], $dir . '/' . $filename)) {
|
||||
json_error(500, 'Failed to store avatar');
|
||||
}
|
||||
|
||||
// Drop the previous upload; an external (social) avatar is just a URL, nothing to delete.
|
||||
$old = (string) ($user['avatar'] ?? '');
|
||||
if ($old !== '' && !str_starts_with($old, 'http') && is_file($dir . '/' . basename($old))) {
|
||||
@unlink($dir . '/' . basename($old));
|
||||
}
|
||||
|
||||
$pdo->prepare('UPDATE auth_users SET avatar = ? WHERE id = ?')->execute([$filename, $user['id']]);
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM auth_users WHERE id = ? LIMIT 1');
|
||||
$stmt->execute([$user['id']]);
|
||||
|
||||
json_data(present_user($stmt->fetch()));
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/me-save.php",
|
||||
* tags={"Auth"},
|
||||
* summary="Update the current user's profile",
|
||||
* description="Partial update: only the keys present in the body are changed. E-mail is read-only here.",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* @OA\Property(property="first_name", type="string", nullable=true),
|
||||
* @OA\Property(property="last_name", type="string", nullable=true),
|
||||
* @OA\Property(property="phone", type="string", nullable=true),
|
||||
* @OA\Property(property="address", type="string", nullable=true)
|
||||
* )),
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/User"))),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$in = body();
|
||||
|
||||
$data = [];
|
||||
foreach (['first_name', 'last_name', 'phone', 'address'] as $col) {
|
||||
if (array_key_exists($col, $in)) {
|
||||
$value = $in[$col];
|
||||
$data[$col] = ($value === null || trim((string) $value) === '') ? null : trim((string) $value);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$data) {
|
||||
json_error(422, 'Nothing to update');
|
||||
}
|
||||
|
||||
$set = implode(', ', array_map(fn ($c) => "$c = ?", array_keys($data)));
|
||||
$pdo->prepare("UPDATE auth_users SET $set WHERE id = ?")
|
||||
->execute([...array_values($data), $user['id']]);
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM auth_users WHERE id = ? LIMIT 1');
|
||||
$stmt->execute([$user['id']]);
|
||||
|
||||
json_data(present_user($stmt->fetch()));
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/me.php",
|
||||
* tags={"Auth"},
|
||||
* summary="Current user",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/User")
|
||||
* )),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
|
||||
json_data(present_user($user));
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
// Generate the OpenAPI spec from @OA annotations in this directory.
|
||||
//
|
||||
// swagger-php is a build tool and is NOT a project dependency. Install it once, e.g.:
|
||||
// composer require --working-dir=/tmp/swg zircote/swagger-php:^4.7 doctrine/annotations
|
||||
// SWAGGER_AUTOLOAD=/tmp/swg/vendor/autoload.php php public/userportal/api/openapi-gen.php
|
||||
//
|
||||
// TokenAnalyser is used so docblock annotations in procedural (class-less) files are read.
|
||||
|
||||
$autoload = getenv('SWAGGER_AUTOLOAD') ?: __DIR__ . '/../../../vendor/autoload.php';
|
||||
require $autoload;
|
||||
|
||||
use OpenApi\Generator;
|
||||
use OpenApi\Analysers\TokenAnalyser;
|
||||
|
||||
if (!class_exists(Generator::class)) {
|
||||
fwrite(STDERR, "swagger-php is not installed. See the file header.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$generator = new Generator();
|
||||
if (class_exists(TokenAnalyser::class)) {
|
||||
$generator->setAnalyser(new TokenAnalyser());
|
||||
}
|
||||
|
||||
$openapi = $generator->generate([__DIR__]);
|
||||
|
||||
$out = __DIR__ . '/openapi.yaml';
|
||||
file_put_contents($out, $openapi->toYaml());
|
||||
|
||||
echo "OpenAPI written to: {$out}\n";
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/owner-delete.php",
|
||||
* tags={"Owners"},
|
||||
* summary="Delete owner",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"owner_id"},
|
||||
* @OA\Property(property="owner_id", type="integer")
|
||||
* )),
|
||||
* @OA\Response(response=200, ref="#/components/responses/Success"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$ownerId = (int) (body()['owner_id'] ?? 0);
|
||||
|
||||
if ($ownerId <= 0) {
|
||||
json_error(422, 'owner_id is required');
|
||||
}
|
||||
if (!user_owns_owner($pdo, $user, $ownerId)) {
|
||||
json_error(403, 'No access to this owner');
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$pdo->prepare('DELETE FROM home_owners WHERE owner_id = ?')->execute([$ownerId]);
|
||||
$pdo->prepare('DELETE FROM property_owners WHERE owner_id = ? AND user_id = ?')->execute([$ownerId, $user['id']]);
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
json_error(500, 'Delete failed');
|
||||
}
|
||||
|
||||
json_ok();
|
||||
@@ -1,76 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/owner-document-upload.php",
|
||||
* tags={"Documents"},
|
||||
* summary="Upload a document file for an owner",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\MediaType(mediaType="multipart/form-data",
|
||||
* @OA\Schema(
|
||||
* required={"owner_id","document_id","file"},
|
||||
* @OA\Property(property="owner_id", type="integer"),
|
||||
* @OA\Property(property="document_id", type="integer"),
|
||||
* @OA\Property(property="file", type="string", format="binary", description="PDF or image"),
|
||||
* @OA\Property(property="expiry_date", type="string", format="date"),
|
||||
* @OA\Property(property="note", type="string"),
|
||||
* @OA\Property(property="title", type="string", description="optional display name")
|
||||
* )
|
||||
* )),
|
||||
* @OA\Response(response=201, description="File uploaded", @OA\JsonContent(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/UploadedFile"))),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$config = require __DIR__ . '/config.php';
|
||||
|
||||
$ownerId = (int) ($_POST['owner_id'] ?? 0);
|
||||
$documentId = (int) ($_POST['document_id'] ?? 0);
|
||||
$expiry = $_POST['expiry_date'] ?? null;
|
||||
$note = $_POST['note'] ?? null;
|
||||
$title = $_POST['title'] ?? null;
|
||||
|
||||
$fields = [];
|
||||
if ($ownerId <= 0) { $fields['owner_id'] = ['Required']; }
|
||||
if ($documentId <= 0) { $fields['document_id'] = ['Required']; }
|
||||
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
||||
$fields['file'] = ['Valid file is required'];
|
||||
} elseif (!is_allowed_upload($_FILES['file']['tmp_name'], $_FILES['file']['name'])) {
|
||||
$fields['file'] = ['Only PDF or image files are allowed'];
|
||||
}
|
||||
if ($fields) {
|
||||
json_error(422, 'Validation failed', $fields);
|
||||
}
|
||||
if (!user_owns_owner($pdo, $user, $ownerId)) {
|
||||
json_error(403, 'No access to this owner');
|
||||
}
|
||||
|
||||
$dir = $config['persondocs_dir'];
|
||||
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
json_error(500, 'Storage directory unavailable');
|
||||
}
|
||||
|
||||
$safe = preg_replace('/[^A-Za-z0-9._-]/', '_', basename($_FILES['file']['name']));
|
||||
$filename = $ownerId . '-' . time() . '-' . $safe;
|
||||
|
||||
if (!move_uploaded_file($_FILES['file']['tmp_name'], $dir . '/' . $filename)) {
|
||||
json_error(500, 'Failed to store file');
|
||||
}
|
||||
|
||||
$expiryStatus = $expiry ? 1 : 0;
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT INTO doc_storage (idhome, entity_type, owner_id, document_id, title, filename, expirystatus, expirydate, note, created_at, updated_at)
|
||||
VALUES (NULL, 'person', ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())"
|
||||
);
|
||||
$stmt->execute([$ownerId, $documentId, $title ?: null, $filename, $expiryStatus, $expiry ?: null, $note ?: null]);
|
||||
$id = (int) $pdo->lastInsertId();
|
||||
|
||||
$row = $pdo->prepare('SELECT * FROM doc_storage WHERE id = ?');
|
||||
$row->execute([$id]);
|
||||
|
||||
json_data(present_file($row->fetch()), 201);
|
||||
@@ -1,58 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/owner-documents.php",
|
||||
* tags={"Documents"},
|
||||
* summary="Owner personal documents with uploaded files",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Parameter(name="owner_id", in="query", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Parameter(name="slug", in="query", @OA\Schema(type="string")),
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", type="array",
|
||||
* @OA\Items(ref="#/components/schemas/DocumentRequirement"))
|
||||
* )),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
$ownerId = (int) query('owner_id', 0);
|
||||
$slug = query('slug');
|
||||
|
||||
if ($ownerId <= 0) {
|
||||
json_error(422, 'owner_id is required');
|
||||
}
|
||||
if (!user_owns_owner($pdo, $user, $ownerId)) {
|
||||
json_error(403, 'No access to this owner');
|
||||
}
|
||||
|
||||
$sql = 'SELECT d.*, s.section_name
|
||||
FROM documents d
|
||||
LEFT JOIN sections s ON s.idsections = d.idsections';
|
||||
$params = [];
|
||||
if ($slug !== null && $slug !== '') {
|
||||
$sql .= ' JOIN pages p ON p.idpages = d.page_id WHERE p.slug = ?';
|
||||
$params[] = $slug;
|
||||
}
|
||||
$sql .= ' ORDER BY s.section_name, d.document_name';
|
||||
|
||||
$docStmt = $pdo->prepare($sql);
|
||||
$docStmt->execute($params);
|
||||
$documents = $docStmt->fetchAll();
|
||||
|
||||
$filesStmt = $pdo->prepare('SELECT * FROM doc_storage WHERE owner_id = ?');
|
||||
$filesStmt->execute([$ownerId]);
|
||||
$filesByDoc = [];
|
||||
foreach ($filesStmt->fetchAll() as $f) {
|
||||
$filesByDoc[(int) $f['document_id']][] = present_file($f);
|
||||
}
|
||||
|
||||
$result = array_map(function ($d) use ($filesByDoc) {
|
||||
$tpl = present_document_template($d);
|
||||
$tpl['files'] = $filesByDoc[(int) $d['document_id']] ?? [];
|
||||
return $tpl;
|
||||
}, $documents);
|
||||
|
||||
json_data($result);
|
||||
@@ -1,80 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/owner-save.php",
|
||||
* tags={"Owners"},
|
||||
* summary="Create or update owner",
|
||||
* description="owner_id missing/0 → create; otherwise update own owner.",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"tax_code"},
|
||||
* @OA\Property(property="owner_id", type="integer"),
|
||||
* @OA\Property(property="owner_type", type="string", enum={"individual","company"}),
|
||||
* @OA\Property(property="first_name", type="string"),
|
||||
* @OA\Property(property="last_name", type="string"),
|
||||
* @OA\Property(property="company_name", type="string"),
|
||||
* @OA\Property(property="tax_code", type="string"),
|
||||
* @OA\Property(property="email", type="string"),
|
||||
* @OA\Property(property="phone", type="string"),
|
||||
* @OA\Property(property="address", type="string"),
|
||||
* @OA\Property(property="postal_code", type="string"),
|
||||
* @OA\Property(property="city", type="string"),
|
||||
* @OA\Property(property="province", type="string"),
|
||||
* @OA\Property(property="country", type="integer"),
|
||||
* @OA\Property(property="role", type="string")
|
||||
* )),
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Owner"))),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$in = body();
|
||||
|
||||
$allowed = [
|
||||
'owner_type', 'first_name', 'last_name', 'company_name', 'tax_code', 'email',
|
||||
'phone', 'address', 'postal_code', 'city', 'province', 'country', 'role',
|
||||
];
|
||||
|
||||
if (trim((string) ($in['tax_code'] ?? '')) === '') {
|
||||
json_error(422, 'Validation failed', ['tax_code' => ['Required']]);
|
||||
}
|
||||
|
||||
$data = [];
|
||||
foreach ($allowed as $col) {
|
||||
if (array_key_exists($col, $in)) {
|
||||
$data[$col] = $in[$col] !== '' ? $in[$col] : null;
|
||||
}
|
||||
}
|
||||
|
||||
$ownerId = (int) ($in['owner_id'] ?? 0);
|
||||
|
||||
if ($ownerId > 0) {
|
||||
if (!user_owns_owner($pdo, $user, $ownerId)) {
|
||||
json_error(403, 'No access to this owner');
|
||||
}
|
||||
if ($data) {
|
||||
$set = implode(', ', array_map(fn ($c) => "$c = ?", array_keys($data)));
|
||||
$pdo->prepare("UPDATE property_owners SET $set WHERE owner_id = ? AND user_id = ?")
|
||||
->execute([...array_values($data), $ownerId, $user['id']]);
|
||||
}
|
||||
} else {
|
||||
$data['user_id'] = $user['id'];
|
||||
// NOT NULL without a default in the legacy schema.
|
||||
$data['owner_type'] ??= 'individual';
|
||||
$data['email'] ??= '';
|
||||
$cols = implode(', ', array_keys($data));
|
||||
$ph = implode(', ', array_fill(0, count($data), '?'));
|
||||
$pdo->prepare("INSERT INTO property_owners ($cols) VALUES ($ph)")
|
||||
->execute(array_values($data));
|
||||
$ownerId = (int) $pdo->lastInsertId();
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM property_owners WHERE owner_id = ? LIMIT 1');
|
||||
$stmt->execute([$ownerId]);
|
||||
|
||||
json_data(present_owner($stmt->fetch()));
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/owner.php",
|
||||
* tags={"Owners"},
|
||||
* summary="Owner details",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Parameter(name="owner_id", in="query", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Owner"))),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
$ownerId = (int) query('owner_id', 0);
|
||||
|
||||
if ($ownerId <= 0) {
|
||||
json_error(422, 'owner_id is required');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM property_owners WHERE owner_id = ? LIMIT 1');
|
||||
$stmt->execute([$ownerId]);
|
||||
$owner = $stmt->fetch();
|
||||
|
||||
if (!$owner) {
|
||||
json_error(404, 'Owner not found');
|
||||
}
|
||||
if ((int) $owner['user_id'] !== (int) $user['id']) {
|
||||
json_error(403, 'No access to this owner');
|
||||
}
|
||||
|
||||
json_data(present_owner($owner));
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/owners.php",
|
||||
* tags={"Owners"},
|
||||
* summary="My owners",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Owner"))
|
||||
* )),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM property_owners WHERE user_id = ? ORDER BY owner_id DESC');
|
||||
$stmt->execute([$user['id']]);
|
||||
|
||||
json_data(array_map('present_owner', $stmt->fetchAll()));
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/pages.php",
|
||||
* tags={"Reference"},
|
||||
* summary="Category pages reference",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Page"))
|
||||
* )),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
require_auth($pdo);
|
||||
|
||||
$stmt = $pdo->query('SELECT * FROM pages ORDER BY idpages');
|
||||
|
||||
json_data(array_map('present_page', $stmt->fetchAll()));
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/password-change.php",
|
||||
* tags={"Auth"},
|
||||
* summary="Change the password of the signed-in user",
|
||||
* description="Revokes every other token; the current device stays signed in.",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"current_password","new_password"},
|
||||
* @OA\Property(property="current_password", type="string", format="password"),
|
||||
* @OA\Property(property="new_password", type="string", format="password", minLength=8)
|
||||
* )),
|
||||
* @OA\Response(response=200, ref="#/components/responses/Success"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
|
||||
$in = body();
|
||||
$current = (string) ($in['current_password'] ?? '');
|
||||
$new = (string) ($in['new_password'] ?? '');
|
||||
|
||||
if (strlen($new) < 8) {
|
||||
json_error(422, 'Validation failed', ['new_password' => ['At least 8 characters']]);
|
||||
}
|
||||
if (!password_verify($current, (string) $user['password'])) {
|
||||
json_error(422, 'Validation failed', ['current_password' => ['Wrong password']]);
|
||||
}
|
||||
|
||||
$pdo->prepare('UPDATE auth_users SET password = ? WHERE id = ?')
|
||||
->execute([password_hash($new, PASSWORD_DEFAULT), $user['id']]);
|
||||
|
||||
// Keep the device that just changed the password signed in.
|
||||
$pdo->prepare('DELETE FROM api_tokens WHERE user_id = ? AND token <> ?')
|
||||
->execute([$user['id'], hash('sha256', (string) bearer_token())]);
|
||||
|
||||
json_ok();
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
require __DIR__ . '/_codes.php';
|
||||
require __DIR__ . '/_mail.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/password-forgot.php",
|
||||
* tags={"Auth"},
|
||||
* summary="Send a password reset code",
|
||||
* description="Always answers 200, even for an unknown e-mail, so the endpoint cannot be used to enumerate accounts.",
|
||||
* security={},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"email"},
|
||||
* @OA\Property(property="email", type="string", format="email")
|
||||
* )),
|
||||
* @OA\Response(response=200, ref="#/components/responses/Success")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
|
||||
$email = trim((string) (body()['email'] ?? ''));
|
||||
|
||||
if ($email !== '') {
|
||||
$stmt = $pdo->prepare('SELECT id FROM auth_users WHERE email = ? LIMIT 1');
|
||||
$stmt->execute([$email]);
|
||||
if ($stmt->fetchColumn() && $code = issue_code($pdo, 'auth_password_resets', $email)) {
|
||||
send_code_mail($email, $code, true);
|
||||
}
|
||||
}
|
||||
|
||||
json_ok();
|
||||