30 Commits

Author SHA1 Message Date
josephdsouza 2321e5389d allow ephemeral host port selection to container port 80 2026-03-03 14:48:38 +01:00
josephdsouza 962a5be29f minor fix 2026-02-11 13:19:38 +01:00
josephdsouza 7a8f16c624 refactor and modularise profile components 2026-02-11 13:19:38 +01:00
josephdsouza cc1a3728e5 add manager for edit profile service 2026-02-11 13:19:38 +01:00
josephdsouza dc46ba0a4b refactor and modularise auth service components 2026-02-11 13:19:38 +01:00
josephdsouza 2e19a3c036 manage all styles in one place 2026-02-11 13:19:38 +01:00
josephdsouza b0caa50e7c refactor and update app navigator 2026-02-11 13:19:38 +01:00
josephdsouza 18474d30ab add info plist entry for camera permission 2026-02-11 13:19:38 +01:00
josephdsouza b213bcee8b refactor home, add feature for document scanning, processing and viewing 2026-02-11 13:19:38 +01:00
josephdsouza 685c6bb2ce add client api methods 2026-02-11 13:19:38 +01:00
josephdsouza da9ba3094c cleanup 2026-02-11 13:19:38 +01:00
josephdsouza d7e30ce781 fix crop issue with server avatar image upload 2026-02-11 13:19:38 +01:00
josephdsouza 51064d95e3 add edit profile feature 2026-02-11 13:19:38 +01:00
josephdsouza 0bde09ad5c enable account deletion 2026-02-11 13:19:38 +01:00
josephdsouza 2b5446555b add profile details and features 2026-02-11 13:19:29 +01:00
josephdsouza 3b571ede07 add package patch to native dependency 2026-02-11 13:19:29 +01:00
josephdsouza 77d319adb2 add native and expo dependencies 2026-02-11 13:19:16 +01:00
josephdsouza 20b3889b2d update dependencies for docker container 2026-02-10 16:29:36 +01:00
josephdsouza ea0951ba85 update gitignore 2026-02-10 16:27:17 +01:00
josephdsouza 10974842ff increase client payload size for nginx server 2026-02-10 16:26:58 +01:00
josephdsouza e51b284181 add tab bar to separate profile and home screens 2026-02-08 04:19:49 +01:00
josephdsouza 012d84b369 set initial build version 2026-02-08 03:03:31 +01:00
josephdsouza 290c62ae52 replace yarn with npm 2026-02-08 03:03:31 +01:00
josephdsouza bd7618d2e5 upgrade expo SDK to 54, upgrade react native and dependent libraries to compatible version 2026-02-08 03:03:20 +01:00
josephdsouza 23c2e00792 set avatar column nullable for users and social logins tables 2026-02-08 03:03:20 +01:00
josephdsouza 22da268739 add docker configuration to run local webserver and app 2026-02-08 03:03:20 +01:00
josephdsouza 30433b7183 initialise and add react native app environment, UI and foundational code for user authentication 2026-02-08 03:03:06 +01:00
josephdsouza 02c713f416 add documentation for project to port php web app to react native 2026-02-08 02:39:26 +01:00
josephdsouza a6785f26db reorganize and cleanup php server code 2026-02-08 02:39:26 +01:00
josephdsouza bf2f18f847 update gitignore 2026-02-08 02:39:12 +01:00
3204 changed files with 18745 additions and 362558 deletions
-43
View File
@@ -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=
+57 -26
View File
@@ -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
-28365
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -1,2 +0,0 @@
*
!.gitignore
+41
View File
@@ -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
+21
View File
@@ -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;
}
}
+2
View File
@@ -0,0 +1,2 @@
upload_max_filesize=100M
post_max_size=100M
+141
View File
@@ -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.
BIN
View File
Binary file not shown.
+33
View File
@@ -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>
);
}
+55
View File
@@ -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"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+6
View File
@@ -0,0 +1,6 @@
module.exports = function(api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};
+8
View File
@@ -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);
+8698
View File
File diff suppressed because it is too large Load Diff
+54
View File
@@ -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';
+98
View File
@@ -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
}
});
+9
View File
@@ -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',
};
+138
View File
@@ -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,
};
};
+49
View File
@@ -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 };
};
+89
View File
@@ -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 };
};
+62
View File
@@ -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,
};
};
+91
View File
@@ -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,
};
};
+73
View File
@@ -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>
);
}
+60
View File
@@ -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>
);
}
+24
View File
@@ -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,
},
};
+99
View File
@@ -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%',
}
});
+174
View File
@@ -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>
);
}
+55
View File
@@ -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%',
},
});
+131
View File
@@ -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>
);
}
+95
View File
@@ -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>
);
}
+232
View File
@@ -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>
);
}
+101
View File
@@ -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,
};
},
};
+194
View File
@@ -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' };
}
},
};
+52
View File
@@ -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);
},
}
)
);
+65
View File
@@ -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;
}
},
}));
+24
View File
@@ -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',
}
};
+238
View File
@@ -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,
},
});
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "expo/tsconfig.base"
}
-2
View File
@@ -1,2 +0,0 @@
*
!.gitignore
Binary file not shown.
-46
View File
@@ -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();
-78
View File
@@ -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()]));
}
-48
View File
@@ -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()]));
}
-25
View File
@@ -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();
-39
View File
@@ -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();
-52
View File
@@ -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();
-187
View File
@@ -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>
-291
View File
@@ -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>
-423
View File
@@ -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;
}
-83
View File
@@ -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;
}
-18
View File
@@ -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
);
}
-146
View File
@@ -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);
}
-244
View File
@@ -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
{
}
-106
View File
@@ -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();
-47
View File
@@ -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()));
-61
View File
@@ -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()));
-78
View File
@@ -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;
-97
View File
@@ -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();
-59
View File
@@ -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),
]);
-41
View File
@@ -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();
-37
View File
@@ -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()));
-62
View File
@@ -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));
-72
View File
@@ -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;
-87
View File
@@ -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));
-39
View File
@@ -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));
-35
View File
@@ -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);
-26
View File
@@ -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);
-68
View File
@@ -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),
]);
-20
View File
@@ -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();
-57
View File
@@ -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()));
-46
View File
@@ -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()));
-19
View File
@@ -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));
-31
View File
@@ -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";
File diff suppressed because it is too large Load Diff
-39
View File
@@ -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);
-58
View File
@@ -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);
-80
View File
@@ -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()));
-36
View File
@@ -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));
-22
View File
@@ -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()));
-21
View File
@@ -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()));
-42
View File
@@ -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();
-32
View File
@@ -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();

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