rental property started

This commit is contained in:
nathanAndrinoid 2026-02-17 04:34:45 +00:00
parent 19678b3b22
commit bfa90b7377
51 changed files with 9459 additions and 1386 deletions

View File

@ -4303,10 +4303,24 @@ body {
cursor: not-allowed;
}
.list-action-button--center {
.list-header-center {
position: absolute;
top: 50%;
left: 50%;
right: auto;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
gap: 0.5rem;
}
.list-header-center .list-action-button {
position: static;
transform: none;
}
.list-action-button--small {
padding: 0.35rem 0.85rem;
font-size: 0.9rem;
}
.dialog-offset {
@ -4620,6 +4634,712 @@ body {
}
}
/* Dashboard layout (landlord + tenant) – no Tailwind */
.dashboard-page {
min-height: 100vh;
background: var(--theme-bg);
}
.dashboard-header {
background: var(--theme-surface);
box-shadow: var(--theme-shadow);
}
.dashboard-header-inner {
max-width: 80rem;
margin-left: auto;
margin-right: auto;
padding-left: 1rem;
padding-right: 1rem;
}
@media (min-width: 640px) {
.dashboard-header-inner {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}
@media (min-width: 1024px) {
.dashboard-header-inner {
padding-left: 2rem;
padding-right: 2rem;
}
}
.dashboard-header-row {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding-top: 1.5rem;
padding-bottom: 1.5rem;
}
.dashboard-header-title {
font-size: 1.875rem;
font-weight: 700;
color: var(--theme-text);
}
.dashboard-header-subtitle {
margin-top: 0.25rem;
font-size: 0.875rem;
color: var(--theme-muted);
}
.dashboard-header-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: stretch;
justify-content: flex-end;
}
.dashboard-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 8.5rem;
flex: 0 0 8.5rem;
padding: 0.5rem 0.75rem;
border-width: 2px;
border-style: solid;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.375rem;
text-decoration: none;
white-space: nowrap;
box-sizing: border-box;
}
.dashboard-btn--primary {
border-color: transparent;
color: #fff;
background: var(--theme-primary-fill);
}
.dashboard-btn--primary:hover {
background: var(--theme-primary-fill-hover);
}
.dashboard-btn--secondary {
border-color: var(--theme-border);
color: var(--theme-text);
background: var(--theme-surface);
}
.dashboard-btn--secondary:hover {
background: var(--landlord-surface-subtle);
}
.dashboard-btn--messages-new {
border-color: var(--theme-accent);
color: var(--theme-text);
background: var(--theme-surface);
box-shadow: var(--theme-shadow);
outline: 2px solid rgba(15, 109, 183, 0.3);
outline-offset: 2px;
}
.dashboard-btn--messages-new:hover {
background: var(--landlord-surface-subtle);
}
.dashboard-content {
max-width: 80rem;
margin-left: auto;
margin-right: auto;
padding: 1rem 1rem 2rem;
}
@media (min-width: 640px) {
.dashboard-content {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}
@media (min-width: 1024px) {
.dashboard-content {
padding-left: 2rem;
padding-right: 2rem;
}
}
.dashboard-stats-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1.25rem;
margin-bottom: 2rem;
}
@media (min-width: 640px) {
.dashboard-stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.dashboard-stats-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.dashboard-two-col {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
}
@media (min-width: 1024px) {
.dashboard-two-col {
grid-template-columns: repeat(2, 1fr);
}
}
.dashboard-card--full-width {
grid-column: 1 / -1;
}
.dashboard-table-wrap {
overflow-x: auto;
}
.dashboard-table {
width: 100%;
border-collapse: collapse;
}
.dashboard-table thead {
background: var(--landlord-surface-subtle);
}
.dashboard-table th,
.dashboard-table td {
padding: 0.75rem 1.5rem;
text-align: left;
font-size: 0.875rem;
}
.dashboard-table-th {
font-weight: 500;
color: var(--theme-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.dashboard-table-td {
color: var(--theme-text);
white-space: nowrap;
}
.dashboard-table tbody tr {
border-bottom: 1px solid var(--theme-border);
}
.dashboard-table tbody tr:last-child {
border-bottom: none;
}
.dashboard-table-empty {
text-align: center;
padding: 1rem 1.5rem;
color: var(--theme-muted);
}
.dashboard-card {
background: var(--theme-surface);
box-shadow: var(--theme-shadow);
border-radius: 0.5rem;
overflow: hidden;
}
.dashboard-card--no-overflow {
overflow: visible;
}
.dashboard-card-body {
padding: 1rem 1.25rem;
}
@media (min-width: 640px) {
.dashboard-card-body {
padding: 1.5rem;
}
}
.dashboard-stat-card {
display: flex;
align-items: center;
}
.dashboard-stat-icon-wrap {
flex-shrink: 0;
}
.dashboard-stat-icon {
width: 1.5rem;
height: 1.5rem;
color: var(--theme-muted);
}
.dashboard-stat-content {
margin-left: 1.25rem;
min-width: 0;
flex: 1;
}
.dashboard-stat-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--theme-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dashboard-stat-value {
font-size: 1.125rem;
font-weight: 500;
color: var(--theme-text);
}
.dashboard-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.dashboard-section-title {
font-size: 1.125rem;
line-height: 1.5;
font-weight: 500;
color: var(--theme-text);
}
.dashboard-section-title--mb {
margin-bottom: 1rem;
}
.dashboard-property-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
}
@media (min-width: 640px) {
.dashboard-property-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.dashboard-property-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.dashboard-section-link {
font-size: 0.875rem;
color: var(--theme-accent);
text-decoration: none;
}
.dashboard-section-link:hover {
color: var(--theme-accent-2);
}
.dashboard-list-spaced > * + * {
margin-top: 1rem;
}
.dashboard-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: var(--landlord-surface-subtle);
border-radius: 0.5rem;
}
.dashboard-row--yellow {
background: rgba(254, 249, 195, 0.6);
}
.dashboard-row--blue {
background: rgba(219, 234, 254, 0.6);
}
.dashboard-row--red {
background: rgba(254, 226, 226, 0.6);
}
@media (prefers-color-scheme: dark) {
:is(.landlord-route, .tenant-route) .dashboard-row--yellow {
background: rgba(120, 100, 40, 0.5);
}
:is(.landlord-route, .tenant-route) .dashboard-row--blue {
background: rgba(30, 58, 100, 0.55);
}
:is(.landlord-route, .tenant-route) .dashboard-row--red {
background: rgba(100, 40, 40, 0.5);
}
}
:root[data-theme-mode="dark"]
:is(.landlord-route, .tenant-route)
.dashboard-row--yellow {
background: rgba(120, 100, 40, 0.5);
}
:root[data-theme-mode="dark"]
:is(.landlord-route, .tenant-route)
.dashboard-row--blue {
background: rgba(30, 58, 100, 0.55);
}
:root[data-theme-mode="dark"]
:is(.landlord-route, .tenant-route)
.dashboard-row--red {
background: rgba(100, 40, 40, 0.5);
}
.dashboard-row-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--theme-text);
}
.dashboard-row-muted {
font-size: 0.875rem;
color: var(--theme-muted);
}
.dashboard-row-meta {
font-size: 0.75rem;
color: var(--theme-muted);
}
.dashboard-row-meta.capitalize,
.dashboard-row-muted.capitalize {
text-transform: capitalize;
}
.dashboard-approval-details {
min-width: 0;
}
.dashboard-approval-details .dashboard-row-muted + .dashboard-row-meta,
.dashboard-approval-details .dashboard-row-label + .dashboard-row-muted {
margin-top: 0.125rem;
}
.dashboard-approval-details .dashboard-row-muted + .dashboard-row-muted,
.dashboard-approval-details .dashboard-row-meta + .dashboard-row-muted {
margin-top: 0.375rem;
}
.dashboard-row-value {
font-size: 0.875rem;
font-weight: 500;
color: var(--theme-text);
}
.dashboard-row-sub {
font-size: 0.75rem;
color: var(--theme-muted);
}
.dashboard-empty {
text-align: center;
padding: 1rem 0;
}
.dashboard-empty-text {
font-size: 0.875rem;
color: var(--theme-muted);
}
.dashboard-empty-link {
font-size: 0.875rem;
color: var(--theme-accent);
text-decoration: none;
}
.dashboard-empty-link:hover {
color: var(--theme-accent-2);
}
.dashboard-message-row {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.dashboard-message-avatar {
flex-shrink: 0;
width: 2rem;
height: 2rem;
border-radius: 9999px;
background: var(--theme-border);
display: flex;
align-items: center;
justify-content: center;
}
.dashboard-message-avatar-text {
font-size: 0.75rem;
font-weight: 500;
color: var(--theme-text);
}
.dashboard-message-body {
flex: 1;
min-width: 0;
}
.dashboard-message-from {
font-size: 0.875rem;
font-weight: 500;
color: var(--theme-text);
}
.dashboard-message-preview {
font-size: 0.875rem;
color: var(--theme-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dashboard-message-meta {
font-size: 0.75rem;
color: var(--theme-muted);
}
.dashboard-message-dot {
flex-shrink: 0;
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
background: var(--theme-accent);
}
.dashboard-badge {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
}
.dashboard-badge--green {
background: rgba(187, 247, 208, 0.8);
color: #166534;
}
.dashboard-badge--green:hover {
background: rgba(134, 239, 172, 0.9);
}
.dashboard-badge--red {
background: rgba(254, 226, 226, 0.8);
color: #b91c1c;
}
.dashboard-badge--red:hover {
background: rgba(252, 165, 165, 0.9);
}
.dashboard-badge--yellow {
background: rgba(254, 249, 195, 0.8);
color: #854d0e;
}
.dashboard-actions-inline {
display: flex;
gap: 0.5rem;
}
.dashboard-actions-inline .dashboard-badge {
cursor: pointer;
border: none;
}
.dashboard-quick-action {
display: block;
text-decoration: none;
color: inherit;
}
.dashboard-quick-action:hover .dashboard-card {
box-shadow: 0 20px 50px rgba(15, 38, 70, 0.2);
}
.dashboard-stat-icon--green {
color: var(--theme-success);
}
.dashboard-stat-icon--blue {
color: var(--theme-accent);
}
.dashboard-stat-icon--purple {
color: #7c3aed;
}
.dashboard-stat-icon--yellow {
color: #ca8a04;
}
.dashboard-badge--red-pill {
padding: 0.125rem 0.625rem;
border-radius: 9999px;
background: rgba(254, 226, 226, 0.8);
color: #b91c1c;
font-size: 0.75rem;
font-weight: 500;
}
.dashboard-error-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--theme-bg);
}
.dashboard-error-inner {
text-align: center;
}
.dashboard-error-title {
font-size: 1.5rem;
font-weight: 700;
color: var(--theme-text);
margin-bottom: 1rem;
}
.dashboard-error-text {
color: var(--theme-muted);
}
.dashboard-error-link {
margin-top: 1rem;
display: inline-block;
color: var(--theme-accent);
text-decoration: none;
}
.dashboard-error-link:hover {
color: var(--theme-accent-2);
}
.dashboard-section-mb {
margin-bottom: 2rem;
}
/* Tenant notification center */
.dashboard-notification-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.dashboard-notification-badge {
display: inline-flex;
align-items: center;
border-radius: 9999px;
background: var(--landlord-surface-subtle);
padding: 0.25rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--theme-muted);
}
.dashboard-notification-grid {
margin-top: 1.5rem;
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@media (min-width: 1024px) {
.dashboard-notification-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.dashboard-notification-card {
border-radius: 0.5rem;
border: 1px solid var(--theme-border);
padding: 1rem;
background: var(--theme-surface);
}
.dashboard-notification-card--highlight {
border-color: rgba(245, 158, 11, 0.5);
background: rgba(255, 251, 235, 0.8);
}
@media (prefers-color-scheme: dark) {
:is(.landlord-route, .tenant-route) .dashboard-notification-card--highlight {
border-color: rgba(180, 83, 9, 0.5);
background: rgba(69, 52, 24, 0.55);
}
:is(.landlord-route, .tenant-route) .dashboard-notification-icon--highlight {
background: rgba(69, 52, 24, 0.7);
color: var(--theme-accent);
}
:is(.landlord-route, .tenant-route) .dashboard-notification-count--highlight {
color: var(--theme-accent);
}
:is(.landlord-route, .tenant-route) .dashboard-notification-dot {
outline-color: rgba(69, 52, 24, 0.9);
}
}
:root[data-theme-mode="dark"]
:is(.landlord-route, .tenant-route)
.dashboard-notification-card--highlight {
border-color: rgba(180, 83, 9, 0.5);
background: rgba(69, 52, 24, 0.55);
}
:root[data-theme-mode="dark"]
:is(.landlord-route, .tenant-route)
.dashboard-notification-icon--highlight {
background: rgba(69, 52, 24, 0.7);
color: var(--theme-accent);
}
:root[data-theme-mode="dark"]
:is(.landlord-route, .tenant-route)
.dashboard-notification-count--highlight {
color: var(--theme-accent);
}
:root[data-theme-mode="dark"]
:is(.landlord-route, .tenant-route)
.dashboard-notification-dot {
outline-color: rgba(69, 52, 24, 0.9);
}
.dashboard-notification-card-inner {
display: flex;
align-items: center;
justify-content: space-between;
}
.dashboard-notification-icon-wrap {
display: flex;
align-items: center;
gap: 0.75rem;
}
.dashboard-notification-icon {
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
background: var(--landlord-surface-subtle);
color: var(--theme-muted);
position: relative;
}
.dashboard-notification-icon--highlight {
background: rgba(254, 243, 199, 0.9);
color: #b45309;
}
.dashboard-notification-icon--blue {
background: rgba(219, 234, 254, 0.8);
color: #1d4ed8;
}
.dashboard-notification-icon--green {
background: rgba(209, 250, 229, 0.8);
color: #047857;
}
.dashboard-notification-icon-svg {
width: 1.25rem;
height: 1.25rem;
}
.dashboard-notification-dot {
position: absolute;
right: -2px;
top: -2px;
width: 0.625rem;
height: 0.625rem;
border-radius: 9999px;
background: #f59e0b;
outline: 2px solid rgba(255, 251, 235, 0.9);
outline-offset: 0;
}
.dashboard-notification-count {
font-size: 0.875rem;
font-weight: 600;
}
.dashboard-notification-count--highlight {
color: #b45309;
}
.dashboard-notification-count--blue {
color: #1d4ed8;
}
.dashboard-notification-count--green {
color: #047857;
}
.dashboard-notification-footer {
margin-top: 0.75rem;
}
.dashboard-notification-footer a {
font-size: 0.75rem;
font-weight: 600;
color: var(--theme-accent);
text-decoration: none;
}
.dashboard-notification-footer a:hover {
color: var(--theme-accent-2);
}
.dashboard-notification-footer a.notification-link--blue {
color: #2563eb;
}
.dashboard-notification-footer a.notification-link--blue:hover {
color: #1d4ed8;
}
.dashboard-notification-footer a.notification-link--green {
color: #059669;
}
.dashboard-notification-footer a.notification-link--green:hover {
color: #047857;
}
.dashboard-invite-list {
margin-top: 0.75rem;
}
.dashboard-invite-list > * + * {
margin-top: 0.75rem;
}
.dashboard-invite-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
border-radius: 0.5rem;
background: var(--landlord-surface-subtle);
padding: 0.75rem 1rem;
}
.dashboard-invite-item-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--theme-text);
}
.dashboard-invite-item-address {
font-size: 0.75rem;
color: var(--theme-muted);
margin-top: 0.125rem;
}
.dashboard-invite-link {
font-size: 0.875rem;
font-weight: 600;
color: var(--theme-accent);
text-decoration: none;
flex-shrink: 0;
}
.dashboard-invite-link:hover {
color: var(--theme-accent-2);
}
.welcome-logo {
left: 70%;
}
@ -5025,6 +5745,13 @@ body {
border: none;
color: #fff;
}
.theme-dialog-submit-button--danger {
background: var(--theme-danger, #b91c1c);
color: #fff;
}
.theme-dialog-submit-button--danger:hover {
filter: brightness(1.1);
}
.theme-dialog-cancel-button {
background: transparent;

View File

@ -2,7 +2,7 @@
* Entry point for the compiled server binary.
* Sets FRESH_ROOT so the server finds _fresh/client and _fresh/server next to the binary, then starts Deno.serve.
*/
import { join, dirname } from "https://deno.land/std@0.224.0/path/mod.ts";
import { dirname, join } from "https://deno.land/std@0.224.0/path/mod.ts";
// Must run before importing the server so the bundle uses this root at load time
const binDir = dirname(Deno.execPath());

205
claude_code_zai_env.sh Normal file
View File

@ -0,0 +1,205 @@
#!/bin/bash
set -euo pipefail
# ========================
# Define Constants
# ========================
SCRIPT_NAME=$(basename "$0")
NODE_MIN_VERSION=18
NODE_INSTALL_VERSION=22
NVM_VERSION="v0.40.3"
CLAUDE_PACKAGE="@anthropic-ai/claude-code"
CONFIG_DIR="$HOME/.claude"
CONFIG_FILE="$CONFIG_DIR/settings.json"
API_BASE_URL="https://api.z.ai/api/anthropic"
API_KEY_URL="https://z.ai/manage-apikey/apikey-list"
API_TIMEOUT_MS=3000000
# ========================
# Functions
# ========================
log_info() {
echo "🔹 $*"
}
log_success() {
echo "✅ $*"
}
log_error() {
echo "❌ $*" >&2
}
ensure_dir_exists() {
local dir="$1"
if [ ! -d "$dir" ]; then
mkdir -p "$dir" || {
log_error "Failed to create directory: $dir"
exit 1
}
fi
}
# ========================
# Node.js Installation
# ========================
install_nodejs() {
local platform=$(uname -s)
case "$platform" in
Linux|Darwin)
log_info "Installing Node.js on $platform..."
# Install nvm
log_info "Installing nvm ($NVM_VERSION)..."
curl -s https://raw.githubusercontent.com/nvm-sh/nvm/"$NVM_VERSION"/install.sh | bash
# Load nvm
log_info "Loading nvm environment..."
\. "$HOME/.nvm/nvm.sh"
# Install Node.js
log_info "Installing Node.js $NODE_INSTALL_VERSION..."
nvm install "$NODE_INSTALL_VERSION"
# Verify installation
node -v &>/dev/null || {
log_error "Node.js installation failed"
exit 1
}
log_success "Node.js installed: $(node -v)"
log_success "npm version: $(npm -v)"
;;
*)
log_error "Unsupported platform: $platform"
exit 1
;;
esac
}
# ========================
# Node.js Check
# ========================
check_nodejs() {
if command -v node &>/dev/null; then
current_version=$(node -v | sed 's/v//')
major_version=$(echo "$current_version" | cut -d. -f1)
if [ "$major_version" -ge "$NODE_MIN_VERSION" ]; then
log_success "Node.js is already installed: v$current_version"
return 0
else
log_info "Node.js v$current_version is installed but version < $NODE_MIN_VERSION. Upgrading..."
install_nodejs
fi
else
log_info "Node.js not found. Installing..."
install_nodejs
fi
}
# ========================
# Claude Code Installation
# ========================
install_claude_code() {
if command -v claude &>/dev/null; then
log_success "Claude Code is already installed: $(claude --version)"
else
log_info "Installing Claude Code..."
npm install -g "$CLAUDE_PACKAGE" || {
log_error "Failed to install claude-code"
exit 1
}
log_success "Claude Code installed successfully"
fi
}
configure_claude_json(){
node --eval '
const os = require("os");
const fs = require("fs");
const path = require("path");
const homeDir = os.homedir();
const filePath = path.join(homeDir, ".claude.json");
if (fs.existsSync(filePath)) {
const content = JSON.parse(fs.readFileSync(filePath, "utf-8"));
fs.writeFileSync(filePath, JSON.stringify({ ...content, hasCompletedOnboarding: true }, null, 2), "utf-8");
} else {
fs.writeFileSync(filePath, JSON.stringify({ hasCompletedOnboarding: true }, null, 2), "utf-8");
}'
}
# ========================
# API Key Configuration
# ========================
configure_claude() {
log_info "Configuring Claude Code..."
echo " You can get your API key from: $API_KEY_URL"
read -s -p "🔑 Please enter your Z.AI API key: " api_key
echo
if [ -z "$api_key" ]; then
log_error "API key cannot be empty. Please run the script again."
exit 1
fi
ensure_dir_exists "$CONFIG_DIR"
# Write settings.json
node --eval '
const os = require("os");
const fs = require("fs");
const path = require("path");
const homeDir = os.homedir();
const filePath = path.join(homeDir, ".claude", "settings.json");
const apiKey = "'"$api_key"'";
const content = fs.existsSync(filePath)
? JSON.parse(fs.readFileSync(filePath, "utf-8"))
: {};
fs.writeFileSync(filePath, JSON.stringify({
...content,
env: {
ANTHROPIC_AUTH_TOKEN: apiKey,
ANTHROPIC_BASE_URL: "'"$API_BASE_URL"'",
API_TIMEOUT_MS: "'"$API_TIMEOUT_MS"'",
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1
}
}, null, 2), "utf-8");
' || {
log_error "Failed to write settings.json"
exit 1
}
log_success "Claude Code configured successfully"
}
# ========================
# Main
# ========================
main() {
echo "🚀 Starting $SCRIPT_NAME"
check_nodejs
install_claude_code
configure_claude_json
configure_claude
echo ""
log_success "🎉 Installation completed successfully!"
echo ""
echo "🚀 You can now start using Claude Code with:"
echo " claude"
}
main "$@"

View File

@ -273,6 +273,33 @@ export interface MaintenanceRequest {
resolved_on?: number | null;
}
export interface PropertyExpense {
id: string;
property_id?: string | null;
category?: string | null;
amount?: number | null;
description?: string | null;
expense_date?: number | null;
payment_method?: string | null;
vendor?: string | null;
receipt_url?: string | null;
is_recurring?: boolean | null;
recurring_frequency?: string | null;
created_on?: number;
updated_on?: number;
}
export interface RentChange {
id: string;
property_id?: string | null;
effective_date?: number | null;
monthly_rent?: number | null;
reason?: string | null;
previous_rent?: number | null;
created_by?: string | null;
created_on?: number;
}
export interface UserPin {
user_id: string;
pin: string;

View File

@ -274,6 +274,35 @@ export const payment_transactions = pgTable("payment_transactions", {
created_on: bigint("created_on", { mode: "number" }),
});
// Property Expenses
export const property_expenses = pgTable("property_expenses", {
id: text("id").primaryKey(),
property_id: text("property_id").references(() => properties.id),
category: text("category"), // 'maintenance', 'repairs', 'utilities', 'insurance', 'taxes', 'supplies', 'services', 'other'
amount: doublePrecision("amount"),
description: text("description"),
expense_date: bigint("expense_date", { mode: "number" }),
payment_method: text("payment_method"), // 'card', 'ach', 'check', 'cash', 'transfer'
vendor: text("vendor"), // Vendor or service provider name
receipt_url: text("receipt_url"), // URL to receipt or invoice document
is_recurring: boolean("is_recurring").default(false),
recurring_frequency: text("recurring_frequency"), // 'monthly', 'quarterly', 'yearly' if recurring
created_on: bigint("created_on", { mode: "number" }),
updated_on: bigint("updated_on", { mode: "number" }),
});
// Rent Changes
export const rent_changes = pgTable("rent_changes", {
id: text("id").primaryKey(),
property_id: text("property_id").references(() => properties.id),
effective_date: bigint("effective_date", { mode: "number" }),
monthly_rent: doublePrecision("monthly_rent"),
reason: text("reason"), // Optional reason for the rent change
previous_rent: doublePrecision("previous_rent"), // Store the previous rent for reference
created_by: text("created_by").references(() => users.id), // User who made the change
created_on: bigint("created_on", { mode: "number" }),
});
// Reminders
export const reminders = pgTable("reminders", {
id: text("id").primaryKey(),
@ -291,7 +320,9 @@ export const reminders = pgTable("reminders", {
// Communication/Messages
export const messages = pgTable("messages", {
id: text("id").primaryKey(),
lease_id: text("lease_id").references(() => leases.id),
lease_id: text("lease_id").references(() => leases.id, {
onDelete: "set null",
}),
sender_id: text("sender_id").references(() => users.id),
recipient_id: text("recipient_id").references(() => users.id),
message_type: text("message_type"), // 'general', 'maintenance', 'payment', 'notice'

View File

@ -0,0 +1,2 @@
ALTER TABLE "messages" DROP CONSTRAINT IF EXISTS "messages_lease_id_leases_id_fk";--> statement-breakpoint
ALTER TABLE "messages" ADD CONSTRAINT "messages_lease_id_leases_id_fk" FOREIGN KEY ("lease_id") REFERENCES "public"."leases"("id") ON DELETE SET NULL ON UPDATE NO ACTION;

View File

@ -0,0 +1,20 @@
CREATE TABLE "property_expenses" (
"id" text PRIMARY KEY NOT NULL,
"property_id" text,
"category" text,
"amount" double precision,
"description" text,
"expense_date" bigint,
"payment_method" text,
"vendor" text,
"receipt_url" text,
"is_recurring" boolean DEFAULT false,
"recurring_frequency" text,
"created_on" bigint,
"updated_on" bigint
);
--> statement-breakpoint
ALTER TABLE "messages" DROP CONSTRAINT "messages_lease_id_leases_id_fk";
--> statement-breakpoint
ALTER TABLE "property_expenses" ADD CONSTRAINT "property_expenses_property_id_properties_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."properties"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "messages" ADD CONSTRAINT "messages_lease_id_leases_id_fk" FOREIGN KEY ("lease_id") REFERENCES "public"."leases"("id") ON DELETE set null ON UPDATE no action;

View File

@ -0,0 +1,13 @@
CREATE TABLE "rent_changes" (
"id" text PRIMARY KEY NOT NULL,
"property_id" text,
"effective_date" bigint,
"monthly_rent" double precision,
"reason" text,
"previous_rent" double precision,
"created_by" text,
"created_on" bigint
);
--> statement-breakpoint
ALTER TABLE "rent_changes" ADD CONSTRAINT "rent_changes_property_id_properties_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."properties"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "rent_changes" ADD CONSTRAINT "rent_changes_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,20 @@
"when": 1770386163089,
"tag": "0001_wandering_wallflower",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1771310799998,
"tag": "0002_powerful_justin_hammer",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1771311449353,
"tag": "0003_strong_namora",
"breakpoints": true
}
]
}

View File

@ -122,9 +122,6 @@ export async function decryptDataWithStoredPassword(
): Promise<string> {
const password = getEncryptionPassword(userId);
if (!password) {
console.log(
"[Encryption] decryptDataWithStoredPassword: no PIN for userId, skipping decrypt",
);
return "";
}
try {
@ -134,25 +131,15 @@ export async function decryptDataWithStoredPassword(
parsed = JSON.parse(decoded) as unknown;
} catch {
// Not JSON: treat as legacy plain base64 payload
console.log(
"[Encryption] decryptDataWithStoredPassword: legacy base64, returning decoded",
);
return decoded;
}
if (isEnvelope(parsed)) {
const out = await decryptEnvelope(password, parsed);
console.log(
"[Encryption] decryptDataWithStoredPassword: envelope decrypted ok",
);
return out;
}
// JSON but not an envelope: legacy
console.log(
"[Encryption] decryptDataWithStoredPassword: JSON payload, returning as-is",
);
return decoded;
} catch (e) {
console.log("[Encryption] decryptDataWithStoredPassword: failed", e);
} catch {
return "";
}
}
@ -197,18 +184,12 @@ const ENCRYPTION_KEY_PREFIX = "encryption_password_";
export function getEncryptionPassword(userId: string): string | null {
if (!userId) {
console.log("[Encryption] getEncryptionPassword: no userId");
return null;
}
try {
const pin = globalThis.localStorage?.getItem(
ENCRYPTION_KEY_PREFIX + userId,
);
console.log(
"[Encryption] getEncryptionPassword:",
pin ? "PIN available" : "no PIN",
"key=" + ENCRYPTION_KEY_PREFIX + userId,
);
return pin;
} catch {
return null;
@ -221,16 +202,13 @@ export function setEncryptionPassword(
password: string,
): boolean {
if (!userId) {
console.log("[Encryption] setEncryptionPassword: no userId, skip");
return false;
}
try {
const key = getUserEncryptionKey(userId);
if (key) globalThis.localStorage?.setItem(key, password);
console.log("[Encryption] setEncryptionPassword: stored for key=" + key);
return true;
} catch (e) {
console.log("[Encryption] setEncryptionPassword: failed", e);
} catch {
return false;
}
}

View File

@ -0,0 +1,89 @@
import { useEffect, useRef, useState } from "preact/hooks";
import { useFocusTrap } from "../utils/useFocusTrap.ts";
/**
* Listens for submit on forms with data-confirm-message and shows a themed
* confirm dialog instead of the browser's native confirm().
* Add to any page and add data-confirm-message="..." (and optionally
* data-confirm-label="Confirm") to forms that should use the themed dialog.
*/
export default function ConfirmSubmitGuard() {
const [state, setState] = useState<
{
message: string;
confirmLabel: string;
form: HTMLFormElement | null;
} | null
>(null);
const modalRef = useRef<HTMLDivElement>(null);
useFocusTrap(state !== null, modalRef);
useEffect(() => {
const handleSubmit = (e: Event) => {
const form = e.target as HTMLFormElement;
const message = form?.getAttribute?.("data-confirm-message");
if (!message || !form) return;
e.preventDefault();
e.stopPropagation();
const confirmLabel = form.getAttribute("data-confirm-label") || "Confirm";
setState({ message, confirmLabel, form });
};
document.addEventListener("submit", handleSubmit, true);
return () => document.removeEventListener("submit", handleSubmit, true);
}, []);
const handleConfirm = () => {
if (state?.form) {
const form = state.form;
setState(null);
form.submit();
} else {
setState(null);
}
};
const handleCancel = () => {
setState(null);
};
if (!state) return null;
return (
<div
ref={modalRef}
class="theme-dialog-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
>
<div
class="theme-dialog-container flex flex-col gap-4"
onClick={(e) => e.stopPropagation()}
>
<h2 id="confirm-dialog-title" class="theme-dialog-title">
Confirm
</h2>
<p class="text-sm opacity-90" style={{ color: "var(--theme-muted)" }}>
{state.message}
</p>
<div class="flex gap-3 justify-end mt-2">
<button
type="button"
class="theme-dialog-cancel-button"
onClick={handleCancel}
>
Cancel
</button>
<button
type="button"
class="theme-dialog-submit-button"
onClick={handleConfirm}
>
{state.confirmLabel}
</button>
</div>
</div>
</div>
);
}

View File

@ -200,19 +200,10 @@ export default function FinancialCalendar(
let encryptionPassword = getEncryptionPassword(userId);
const hasEncrypted = obligations.some((o) => o.encrypted_data) ||
incomes.some((i) => i.encrypted_data);
console.log(
"[Calendar] processObligations: userId=" +
(userId ? "set" : "missing") + " pin=" +
(encryptionPassword ? "available" : "missing") + " hasEncrypted=" +
hasEncrypted,
);
// If no local PIN but we have encrypted data, try to fetch PIN from server (e.g. new device or calendar opened first)
if (!encryptionPassword && hasEncrypted) {
try {
console.log(
"[Calendar] processObligations: fetching PIN from /api/sync-pin/get",
);
const res = await fetch("/api/sync-pin/get", {
credentials: "include",
cache: "no-store",
@ -221,32 +212,14 @@ export default function FinancialCalendar(
const data = await res.json();
if (data.pin && setEncryptionPassword(userId, data.pin)) {
encryptionPassword = data.pin;
console.log(
"[Calendar] processObligations: PIN stored from server, using for decrypt",
);
} else {
console.log(
"[Calendar] processObligations: server response ok but no pin or set failed",
);
}
} else {
console.log(
"[Calendar] processObligations: sync-pin/get status=" +
res.status,
);
}
} catch (_e) {
console.log(
"[Calendar] processObligations: sync-pin/get fetch failed",
_e,
);
// sync-pin/get fetch failed
}
}
if (!encryptionPassword) {
console.log(
"[Calendar] processObligations: no PIN, using obligations as-is",
);
dispatch({ type: "SET_PROCESSED_OBLIGATIONS", payload: obligations });
return;
}
@ -265,7 +238,6 @@ export default function FinancialCalendar(
);
if (decryptedData) {
const parsed = JSON.parse(decryptedData);
console.log("[Calendar] decrypted obligation JSON:", parsed);
if (parsed.amount !== undefined) {
processedObligation.amount = parsed.amount;
}
@ -274,23 +246,14 @@ export default function FinancialCalendar(
}
decryptedCount++;
}
} catch (error) {
console.error(
"[Calendar] Failed to decrypt obligation data:",
error,
);
} catch {
// decrypt failed for this obligation
}
}
updatedObligations.push(processedObligation);
}
if (decryptedCount > 0) {
console.log(
"[Calendar] processObligations: decrypted " + decryptedCount +
" obligation(s) with PIN",
);
}
dispatch({
type: "SET_PROCESSED_OBLIGATIONS",
payload: updatedObligations,
@ -310,17 +273,9 @@ export default function FinancialCalendar(
let encryptionPassword = getEncryptionPassword(userId);
const hasEncrypted = obligations.some((o) => o.encrypted_data) ||
incomes.some((i) => i.encrypted_data);
console.log(
"[Calendar] processIncomes: userId=" + (userId ? "set" : "missing") +
" pin=" + (encryptionPassword ? "available" : "missing") +
" hasEncrypted=" + hasEncrypted,
);
if (!encryptionPassword && hasEncrypted) {
try {
console.log(
"[Calendar] processIncomes: fetching PIN from /api/sync-pin/get",
);
const res = await fetch("/api/sync-pin/get", {
credentials: "include",
cache: "no-store",
@ -329,29 +284,14 @@ export default function FinancialCalendar(
const data = await res.json();
if (data.pin && setEncryptionPassword(userId, data.pin)) {
encryptionPassword = data.pin;
console.log(
"[Calendar] processIncomes: PIN stored from server, using for decrypt",
);
} else {
console.log(
"[Calendar] processIncomes: server response ok but no pin or set failed",
);
}
} else {
console.log(
"[Calendar] processIncomes: sync-pin/get status=" + res.status,
);
}
} catch (_e) {
console.log(
"[Calendar] processIncomes: sync-pin/get fetch failed",
_e,
);
// sync-pin/get fetch failed
}
}
if (!encryptionPassword) {
console.log("[Calendar] processIncomes: no PIN, using incomes as-is");
dispatch({ type: "SET_PROCESSED_INCOMES", payload: incomes });
return;
}
@ -370,7 +310,6 @@ export default function FinancialCalendar(
);
if (decryptedData) {
const parsed = JSON.parse(decryptedData);
console.log("[Calendar] decrypted income JSON:", parsed);
if (parsed.amount !== undefined) {
processedIncome.amount = parsed.amount;
}
@ -379,20 +318,14 @@ export default function FinancialCalendar(
}
decryptedCount++;
}
} catch (error) {
console.error("[Calendar] Failed to decrypt income data:", error);
} catch {
// decrypt failed for this income
}
}
updatedIncomes.push(processedIncome);
}
if (decryptedCount > 0) {
console.log(
"[Calendar] processIncomes: decrypted " + decryptedCount +
" income(s) with PIN",
);
}
dispatch({ type: "SET_PROCESSED_INCOMES", payload: updatedIncomes });
};

View File

@ -128,6 +128,124 @@ export default function IncomeList(
processIncomes();
}, []);
async function handleExportIncomes() {
const exportData = processedIncomes.map((inc) => ({
id: inc.id,
source: inc.source ?? "",
amount: inc.amount ?? 0,
frequency: inc.frequency ?? "",
received_date: inc.received_date
? new Date(inc.received_date).toISOString().split("T")[0]
: null,
}));
const json = JSON.stringify(exportData, null, 2);
const blob = new Blob([json], { type: "application/json" });
const suggestedName = `incomes-${
new Date().toISOString().split("T")[0]
}.json`;
const showSave = (globalThis as { showSaveFilePicker?: unknown })
.showSaveFilePicker;
if (typeof showSave === "function") {
try {
const handle = await (showSave as (opts: {
suggestedName: string;
types: { description: string; accept: Record<string, string[]> }[];
}) => Promise<FileSystemFileHandle>)({
suggestedName,
types: [{
description: "JSON file",
accept: { "application/json": [".json"] },
}],
});
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
} catch (err) {
if ((err as Error).name !== "AbortError") {
console.error("Export failed:", err);
fallbackDownload(blob, suggestedName);
}
}
} else {
fallbackDownload(blob, suggestedName);
}
}
function fallbackDownload(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
async function handleImportIncomes() {
let file: File;
const showOpen = (globalThis as { showOpenFilePicker?: unknown })
.showOpenFilePicker;
if (typeof showOpen === "function") {
try {
const [handle] = await (showOpen as (opts: {
types: { description: string; accept: Record<string, string[]> }[];
multiple: boolean;
}) => Promise<FileSystemFileHandle[]>)({
types: [{
description: "JSON file",
accept: { "application/json": [".json"] },
}],
multiple: false,
});
file = await handle.getFile();
} catch (err) {
if ((err as Error).name !== "AbortError") {
console.error("Import file picker failed:", err);
}
return;
}
} else {
const chosen = await new Promise<File | null>((resolve) => {
const input = document.createElement("input");
input.type = "file";
input.accept = "application/json,.json";
input.onchange = () => resolve(input.files?.[0] ?? null);
input.click();
});
if (!chosen) return;
file = chosen;
}
try {
const text = await file.text();
const data = JSON.parse(text) as unknown;
const list = Array.isArray(data)
? data
: (data as { incomes?: unknown[] })?.incomes;
if (!Array.isArray(list)) {
alert("Invalid file: expected a JSON array or { incomes: [] }");
return;
}
const res = await fetch("/api/import/incomes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(list),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert(err?.error ?? "Import failed");
return;
}
const result = await res.json() as { added: number; skipped: number };
if (result.added > 0 || result.skipped > 0) {
globalThis.location.reload();
}
} catch (e) {
console.error("Import failed:", e);
alert("Invalid JSON or import failed.");
}
}
return (
<>
{popupAddIncomeDialog}
@ -136,6 +254,22 @@ export default function IncomeList(
<h2 class="theme-text list-title">
Your Incomes
</h2>
<div class="list-header-center">
<button
type="button"
class="list-action-button list-action-button--small"
onClick={handleExportIncomes}
>
Export JSON
</button>
<button
type="button"
class="list-action-button list-action-button--small"
onClick={handleImportIncomes}
>
Import JSON
</button>
</div>
<button
type="button"
class="list-action-button"

View File

@ -0,0 +1,220 @@
import { useState } from "preact/hooks";
import type { ConfirmAction } from "./LeaseApprovalConfirmDialog.tsx";
import LeaseApprovalConfirmDialog from "./LeaseApprovalConfirmDialog.tsx";
export interface PendingApprovalItem {
id: string;
property?: {
title?: string | null;
address_1?: string | null;
city?: string | null;
state?: string | null;
zip?: string | null;
monthly_rent?: number | null;
bedrooms?: number | null;
bathrooms?: number | null;
} | null;
property_address?: string | null;
tenant?: { name?: string | null; email?: string | null } | null;
approval_requested_at?: number | null;
}
interface LandlordPendingApprovalsProps {
approvals: PendingApprovalItem[];
}
function formatAddress(prop: PendingApprovalItem["property"]): string | null {
if (!prop) return null;
const parts = [
prop.address_1,
[prop.city, prop.state].filter(Boolean).join(", "),
prop.zip,
].filter(Boolean);
return parts.length > 0 ? parts.join(", ") : null;
}
function formatRentBeds(prop: PendingApprovalItem["property"]): string | null {
if (!prop) return null;
const hasRent = prop.monthly_rent != null;
const hasBeds = prop.bedrooms != null || prop.bathrooms != null;
if (!hasRent && !hasBeds) return null;
const rent = hasRent
? `$${Number(prop.monthly_rent).toLocaleString()}/mo`
: null;
const beds = hasBeds
? `${prop.bedrooms ?? "—"} bed, ${prop.bathrooms ?? "—"} bath`
: null;
return [rent, beds].filter(Boolean).join(" · ");
}
export default function LandlordPendingApprovals({
approvals,
}: LandlordPendingApprovalsProps) {
const [confirmState, setConfirmState] = useState<
{
action: ConfirmAction;
leaseId: string;
} | null
>(null);
const [submitting, setSubmitting] = useState(false);
const handleApproveClick = (e: Event, leaseId: string) => {
e.preventDefault();
setConfirmState({ action: "approve", leaseId });
};
const handleRejectClick = (e: Event, leaseId: string) => {
e.preventDefault();
setConfirmState({ action: "reject", leaseId });
};
const handleConfirm = async () => {
if (!confirmState) return;
setSubmitting(true);
const url = confirmState.action === "approve"
? "/api/lease/approve"
: "/api/lease/reject";
const body = confirmState.action === "reject"
? { lease_id: confirmState.leaseId, rejection_reason: "" }
: { lease_id: confirmState.leaseId };
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.redirected) {
window.location.href = res.url;
return;
}
if (res.ok) {
window.location.reload();
return;
}
} finally {
setSubmitting(false);
setConfirmState(null);
}
};
const handleCancel = () => {
if (!submitting) setConfirmState(null);
};
return (
<>
<div class="dashboard-card dashboard-card--no-overflow">
<div class="dashboard-card-body">
<div class="dashboard-section-header">
<h3 class="dashboard-section-title">
Pending Lease Approvals
</h3>
<a href="/landlord/approvals" class="dashboard-section-link">
View all
</a>
</div>
<div class="dashboard-list-spaced">
{approvals && approvals.length > 0
? (
approvals.slice(0, 3).map((approval) => {
const prop = approval.property;
const addressLine = formatAddress(prop);
const rentBeds = formatRentBeds(prop);
return (
<div
key={approval.id}
class="dashboard-row dashboard-row--blue"
>
<div class="dashboard-approval-details">
<p class="dashboard-row-label">
{prop?.title || approval.property_address ||
"Property"}
</p>
{addressLine && (
<p class="dashboard-row-muted">{addressLine}</p>
)}
{rentBeds && (
<p class="dashboard-row-meta">{rentBeds}</p>
)}
<p class="dashboard-row-muted">
Tenant: {approval.tenant?.name ||
approval.tenant?.email || "Unknown"}
</p>
<p class="dashboard-row-meta">
Requested {approval.approval_requested_at
? new Date(
approval.approval_requested_at as number,
)
.toLocaleDateString()
: "Unknown date"}
</p>
</div>
<div class="dashboard-actions-inline">
<form
method="POST"
action="/api/lease/approve"
onSubmit={(e) => {
e.preventDefault();
handleApproveClick(e, approval.id);
}}
>
<input
type="hidden"
name="lease_id"
value={approval.id}
/>
<button
type="submit"
class="dashboard-badge dashboard-badge--green"
disabled={submitting}
>
Approve
</button>
</form>
<form
method="POST"
action="/api/lease/reject"
onSubmit={(e) => {
e.preventDefault();
handleRejectClick(e, approval.id);
}}
>
<input
type="hidden"
name="lease_id"
value={approval.id}
/>
<button
type="submit"
class="dashboard-badge dashboard-badge--red"
disabled={submitting}
>
Reject
</button>
</form>
</div>
</div>
);
})
)
: (
<div class="dashboard-empty">
<p class="dashboard-empty-text">
No pending lease approvals
</p>
</div>
)}
</div>
</div>
</div>
<LeaseApprovalConfirmDialog
open={confirmState !== null}
action={confirmState?.action ?? "approve"}
leaseId={confirmState?.leaseId ?? ""}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
</>
);
}

View File

@ -0,0 +1,75 @@
import { useRef } from "preact/hooks";
import { useFocusTrap } from "../utils/useFocusTrap.ts";
export type ConfirmAction = "approve" | "reject";
interface LeaseApprovalConfirmDialogProps {
open: boolean;
action: ConfirmAction;
leaseId: string;
onConfirm: () => void;
onCancel: () => void;
}
const MESSAGES: Record<ConfirmAction, string> = {
approve:
"Are you sure you want to approve this lease? This will make the lease active and the tenant will be able to access the property.",
reject: "Are you sure you want to reject this lease request?",
};
const CONFIRM_LABELS: Record<ConfirmAction, string> = {
approve: "Approve",
reject: "Reject",
};
export default function LeaseApprovalConfirmDialog({
open,
action,
onConfirm,
onCancel,
}: LeaseApprovalConfirmDialogProps) {
const modalRef = useRef<HTMLDivElement>(null);
useFocusTrap(open, modalRef);
if (!open) return null;
return (
<div
ref={modalRef}
class="theme-dialog-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="lease-confirm-title"
>
<div
class="theme-dialog-container flex flex-col gap-4"
onClick={(e) => e.stopPropagation()}
>
<h2 id="lease-confirm-title" class="theme-dialog-title">
{action === "approve" ? "Approve lease" : "Reject lease request"}
</h2>
<p class="text-sm opacity-90" style={{ color: "var(--theme-muted)" }}>
{MESSAGES[action]}
</p>
<div class="flex gap-3 justify-end mt-2">
<button
type="button"
class="theme-dialog-cancel-button"
onClick={onCancel}
>
Cancel
</button>
<button
type="button"
class={action === "reject"
? "theme-dialog-submit-button theme-dialog-submit-button--danger"
: "theme-dialog-submit-button"}
onClick={onConfirm}
>
{CONFIRM_LABELS[action]}
</button>
</div>
</div>
</div>
);
}

View File

@ -25,16 +25,17 @@ export default function MemberHome(
const loginPurpose = localStorage.getItem("login-purpose");
if (loginPurpose) {
localStorage.removeItem("login-purpose");
const tenantDone = Boolean(onboardingStatus?.onboarded_tenant);
const destination = loginPurpose === "landlord"
? "/landlord/dashboard"
: loginPurpose === "tenant"
? "/tenant/dashboard"
? (tenantDone ? "/tenant/dashboard" : "/tenant/onboarding")
: "";
if (destination) {
globalThis.location.href = destination;
}
}
}, []);
}, [onboardingStatus]);
const tileIds = [
{ id: "landlord-hub", label: "Landlord" },
@ -60,7 +61,7 @@ export default function MemberHome(
const tenantPrimary = tenantOnboarded
? { label: "Open Tenant Dashboard", href: "/tenant/dashboard" }
: { label: "Continue Tenant Setup", href: "/tenant/dashboard" };
: { label: "Continue Tenant Setup", href: "/tenant/onboarding" };
const year = serverCalendarYear ?? new Date().getFullYear();
const month = serverCalendarMonth ?? new Date().getMonth() + 1;

View File

@ -182,9 +182,14 @@ export default function ObligationList(
new Date().toISOString().split("T")[0]
}.json`;
if (typeof globalThis.showSaveFilePicker === "function") {
const showSave = (globalThis as { showSaveFilePicker?: unknown })
.showSaveFilePicker;
if (typeof showSave === "function") {
try {
const handle = await globalThis.showSaveFilePicker({
const handle = await (showSave as (opts: {
suggestedName: string;
types: { description: string; accept: Record<string, string[]> }[];
}) => Promise<FileSystemFileHandle>)({
suggestedName,
types: [{
description: "JSON file",
@ -214,6 +219,71 @@ export default function ObligationList(
URL.revokeObjectURL(url);
}
async function handleImportObligations() {
let file: File;
const showOpen = (globalThis as { showOpenFilePicker?: unknown })
.showOpenFilePicker;
if (typeof showOpen === "function") {
try {
const [handle] = await (showOpen as (opts: {
types: { description: string; accept: Record<string, string[]> }[];
multiple: boolean;
}) => Promise<FileSystemFileHandle[]>)({
types: [{
description: "JSON file",
accept: { "application/json": [".json"] },
}],
multiple: false,
});
file = await handle.getFile();
} catch (err) {
if ((err as Error).name !== "AbortError") {
console.error("Import file picker failed:", err);
}
return;
}
} else {
const chosen = await new Promise<File | null>((resolve) => {
const input = document.createElement("input");
input.type = "file";
input.accept = "application/json,.json";
input.onchange = () => resolve(input.files?.[0] ?? null);
input.click();
});
if (!chosen) return;
file = chosen;
}
try {
const text = await file.text();
const data = JSON.parse(text) as unknown;
const list = Array.isArray(data)
? data
: (data as { obligations?: unknown[] })?.obligations;
if (!Array.isArray(list)) {
alert("Invalid file: expected a JSON array or { obligations: [] }");
return;
}
const res = await fetch("/api/import/obligations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(list),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert(err?.error ?? "Import failed");
return;
}
const result = await res.json() as { added: number; skipped: number };
if (result.added > 0 || result.skipped > 0) {
globalThis.location.reload();
}
} catch (e) {
console.error("Import failed:", e);
alert("Invalid JSON or import failed.");
}
}
return (
<>
{popupAddObligationDialog}
@ -222,13 +292,22 @@ export default function ObligationList(
<h2 class="theme-text list-title">
Your Obligations
</h2>
<div class="list-header-center">
<button
type="button"
class="list-action-button list-action-button--center"
class="list-action-button list-action-button--small"
onClick={handleExportObligations}
>
Export JSON
</button>
<button
type="button"
class="list-action-button list-action-button--small"
onClick={handleImportObligations}
>
Import JSON
</button>
</div>
<button
type="button"
class="list-action-button"

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from "preact/hooks";
import { WelcomeBarProps, WelcomeBarState } from "./welcome-types.ts";
import { useEncryption } from "./useEncryption.ts";
import { useLogoutCleanup, useServiceWorker } from "./useServiceWorker.ts";
import { useLogoutCleanup } from "./useServiceWorker.ts";
import EncryptionModal from "./EncryptionModal.tsx";
import NavigationMenu from "./NavigationMenu.tsx";
import LockIcon from "./LockIcon.tsx";
@ -46,7 +46,6 @@ export default function WelcomeBar(
};
useLogoutCleanup();
useServiceWorker();
useEffect(() => {
const syncRoute = () => {

View File

@ -148,9 +148,6 @@ export function useEncryption(
// Check localStorage directly to see if password is already set
const existingPassword = getEncryptionPassword(userId);
if (existingPassword) {
console.log(
"[Encryption] useEncryption: local PIN already set for userId",
);
updateState({
isPasswordSet: true,
isSkipped: false,
@ -182,14 +179,8 @@ export function useEncryption(
if (me.ok) {
const userData = await me.json() as User;
if (userData.id && userData.encrypted_data) {
console.log(
"[Encryption] useEncryption: user has encrypted_data, checking local pin",
);
const localPin = getEncryptionPassword(userId);
if (localPin) {
console.log(
"[Encryption] useEncryption: local pin found, decrypting user data",
);
const decryptedData = await decryptDataWithStoredPassword(
userId,
userData.encrypted_data,
@ -197,9 +188,6 @@ export function useEncryption(
if (decryptedData) {
const decryptedUserData = JSON.parse(decryptedData);
if (decryptedUserData.id === userData.id) {
console.log(
"[Encryption] useEncryption: local decrypt ok, user id match",
);
updateState({
isPasswordSet: true,
isSkipped: false,
@ -207,15 +195,8 @@ export function useEncryption(
});
return;
}
} else {
console.log(
"[Encryption] useEncryption: local decrypt returned empty",
);
}
} else {
console.log(
"[Encryption] useEncryption: no local pin, fetching from server",
);
const response = await fetch("/api/sync-pin/get", {
credentials: "include",
cache: "no-store",
@ -227,10 +208,6 @@ export function useEncryption(
if (response.ok) {
const serverPinData = await response.json();
console.log(
"[Encryption] useEncryption: server pin response ok",
serverPinData.pin ? "pin present" : "no pin",
);
// Server has a pin, store it in localStorage for local usage
if (serverPinData.pin) {
const decryptedData = await tryDecryptDataWithPin(
@ -240,9 +217,6 @@ export function useEncryption(
if (decryptedData) {
const decryptedUserData = JSON.parse(decryptedData);
if (decryptedUserData.id === userData.id) {
console.log(
"[Encryption] useEncryption: server pin decrypt ok, storing locally",
);
const localSetSuccess = setEncryptionPassword(
userId,
serverPinData.pin,
@ -258,10 +232,6 @@ export function useEncryption(
return;
}
}
} else {
console.log(
"[Encryption] useEncryption: server pin decrypt failed or no user id match",
);
}
// Server pin was not usable for this encrypted data, fall back to asking user

View File

@ -31,44 +31,6 @@ function safeSessionStorage() {
}
}
/**
* Ensures no Service Worker is used (temporary).
* Unregisters all service workers and verifies none remain.
*/
export function useServiceWorker() {
useEffect(() => {
if (!("serviceWorker" in navigator)) return;
navigator.serviceWorker.getRegistrations()
.then((registrations) => {
if (registrations.length === 0) {
console.log("[SW] No service workers registered (OK).");
return [] as ServiceWorkerRegistration[];
}
const unregisterPromises = registrations.map((reg) =>
reg.unregister().then(() => {
console.log("[SW] Unregistered:", reg.scope);
}).catch((err) => {
console.warn("[SW] Unregister failed for", reg.scope, err);
})
);
return Promise.all(unregisterPromises).then(() =>
navigator.serviceWorker.getRegistrations()
);
})
.then((after) => {
if (after && after.length > 0) {
console.warn("[SW] Still registered after unregister:", after.length);
} else {
console.log("[SW] Verified: no service workers registered.");
}
})
.catch((err) => {
console.warn("[SW] Check failed:", err);
});
}, []);
}
/**
* Custom hook to manage logout button state
* Handles post-logout redirect cleanup

View File

@ -0,0 +1,130 @@
// routes/api/import/incomes.ts
// Import incomes from JSON; skip items that already exist (by id or by content).
import { define } from "../../../utils.ts";
import {
createIncome,
getIncomesByUser,
} from "../../../services/financhalServices.ts";
import { SessionCookie } from "../../auth/callback.ts";
type ImportItem = {
id?: string;
source?: string | null;
amount?: number | null;
frequency?: string | null;
received_date?: string | number | null;
};
function signature(inc: {
source?: string | null;
amount?: number | null;
frequency?: string | null;
received_date?: number | null;
}): string {
const r = inc.received_date ?? 0;
return `${inc.source ?? ""}|${inc.amount ?? 0}|${inc.frequency ?? ""}|${r}`;
}
export const handler = define.handlers({
async POST(ctx) {
try {
const stateData: SessionCookie = ctx.state.data;
const user_id = stateData?.user_id;
if (!user_id) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{ status: 401, headers: { "Content-Type": "application/json" } },
);
}
const contentType = ctx.req.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
return new Response(
JSON.stringify({ error: "Content-Type must be application/json" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
const body = await ctx.req.json();
const raw = Array.isArray(body) ? body : body?.incomes;
if (!Array.isArray(raw)) {
return new Response(
JSON.stringify({
error: "Body must be an array or { incomes: [] }",
}),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
const existing = await getIncomesByUser(user_id);
const existingIds = new Set(existing.map((i) => i.id));
const existingSigs = new Set(
existing.map((i) =>
signature({
source: i.source,
amount: i.amount,
frequency: i.frequency,
received_date: i.received_date,
})
),
);
let added = 0;
for (const item of raw as ImportItem[]) {
const id = typeof item.id === "string" ? item.id : undefined;
if (id && existingIds.has(id)) continue;
const amount = item.amount != null ? Number(item.amount) : null;
const receivedRaw = item.received_date;
let received_date: number;
if (receivedRaw == null || receivedRaw === "") {
received_date = Date.now();
} else if (typeof receivedRaw === "number") {
received_date = receivedRaw;
} else {
received_date =
Date.parse(String(receivedRaw).replace(/Z?$/, "T00:00:00")) ||
Date.now();
}
const src = item.source != null ? String(item.source) : null;
const freq = (item.frequency != null ? String(item.frequency) : "") ||
"monthly";
const sig = signature({
source: src,
amount,
frequency: freq,
received_date,
});
if (existingSigs.has(sig)) continue;
await createIncome(
user_id,
src,
amount != null && amount > 0 ? amount : null,
freq,
received_date,
"",
);
added++;
existingSigs.add(sig);
}
return new Response(
JSON.stringify({
added,
total: raw.length,
skipped: raw.length - added,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
} catch (e) {
console.error("Import incomes error:", e);
return new Response(
JSON.stringify({ error: "Failed to import incomes" }),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}
},
});

View File

@ -0,0 +1,130 @@
// routes/api/import/obligations.ts
// Import obligations from JSON; skip items that already exist (by id or by content).
import { define } from "../../../utils.ts";
import {
createObligation,
getObligationsByUser,
} from "../../../services/financhalServices.ts";
import { SessionCookie } from "../../auth/callback.ts";
type ImportItem = {
id?: string;
description?: string | null;
amount?: number | null;
frequency?: string | null;
due_date?: string | number | null;
};
function signature(ob: {
description?: string | null;
amount?: number | null;
frequency?: string | null;
due_date?: number | null;
}): string {
const d = ob.due_date ?? 0;
return `${ob.description ?? ""}|${ob.amount ?? 0}|${ob.frequency ?? ""}|${d}`;
}
export const handler = define.handlers({
async POST(ctx) {
try {
const stateData: SessionCookie = ctx.state.data;
const user_id = stateData?.user_id;
if (!user_id) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{ status: 401, headers: { "Content-Type": "application/json" } },
);
}
const contentType = ctx.req.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
return new Response(
JSON.stringify({ error: "Content-Type must be application/json" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
const body = await ctx.req.json();
const raw = Array.isArray(body) ? body : body?.obligations;
if (!Array.isArray(raw)) {
return new Response(
JSON.stringify({
error: "Body must be an array or { obligations: [] }",
}),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
const existing = await getObligationsByUser(user_id);
const existingIds = new Set(existing.map((o) => o.id));
const existingSigs = new Set(
existing.map((o) =>
signature({
description: o.description,
amount: o.amount,
frequency: o.frequency,
due_date: o.due_date,
})
),
);
let added = 0;
for (const item of raw as ImportItem[]) {
const id = typeof item.id === "string" ? item.id : undefined;
if (id && existingIds.has(id)) continue;
const amount = item.amount != null ? Number(item.amount) : null;
const dueDateRaw = item.due_date;
let due_date: number;
if (dueDateRaw == null || dueDateRaw === "") {
due_date = 0;
} else if (typeof dueDateRaw === "number") {
due_date = dueDateRaw;
} else {
due_date =
Date.parse(String(dueDateRaw).replace(/Z?$/, "T00:00:00")) ||
0;
}
const desc = item.description != null ? String(item.description) : null;
const freq = (item.frequency != null ? String(item.frequency) : "") ||
"monthly";
const sig = signature({
description: desc,
amount,
frequency: freq,
due_date: due_date || undefined,
});
if (existingSigs.has(sig)) continue;
await createObligation(
user_id,
desc,
amount != null && amount > 0 ? amount : null,
freq,
due_date,
"",
);
added++;
existingSigs.add(sig);
}
return new Response(
JSON.stringify({
added,
total: raw.length,
skipped: raw.length - added,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
} catch (e) {
console.error("Import obligations error:", e);
return new Response(
JSON.stringify({ error: "Failed to import obligations" }),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}
},
});

View File

@ -58,17 +58,23 @@ export const handler = define.handlers({
);
}
if (lease.owner_id !== user_id) {
// Check if user owns the property (pending leases have owner_id=null)
if (lease.property_id) {
const property = await getPropertyById(lease.property_id);
if (!property || property.owner_id !== user_id) {
return new Response(
JSON.stringify({ error: "Unauthorized: You do not own this lease" }),
JSON.stringify({
error: "Unauthorized: You do not own this property",
}),
{
status: 403,
headers: { "Content-Type": "application/json" },
},
);
}
}
const result = await approveLease(lease_id, user_id);
const result = await approveLease(lease_id, user_id, lease.property_id);
if (!result || result.length === 0) {
return new Response(

View File

@ -60,17 +60,28 @@ export const handler = define.handlers({
);
}
if (lease.owner_id !== user_id) {
// Check if user owns the property (pending leases have owner_id=null)
if (lease.property_id) {
const property = await getPropertyById(lease.property_id);
if (!property || property.owner_id !== user_id) {
return new Response(
JSON.stringify({ error: "Unauthorized: You do not own this lease" }),
JSON.stringify({
error: "Unauthorized: You do not own this property",
}),
{
status: 403,
headers: { "Content-Type": "application/json" },
},
);
}
}
const result = await rejectLease(lease_id, user_id, rejection_reason);
const result = await rejectLease(
lease_id,
user_id,
rejection_reason,
lease.property_id,
);
if (!result || result.length === 0) {
return new Response(

View File

@ -8,6 +8,7 @@ import {
} from "../../../services/tenantInviteService.ts";
import { getPropertyById } from "../../../services/propertyService.ts";
import { getUserById } from "../../../services/userService.ts";
import { createMessage } from "../../../services/messageService.ts";
import {
sendLeaseApprovalRequest,
} from "../../../services/notificationService.ts";
@ -102,13 +103,26 @@ export const handler = define.handlers({
if (invite.landlord_id) {
const tenant = await getUserById(user_id);
const landlord = await getUserById(invite.landlord_id);
const propertyAddress = property.address_1
? `${property.address_1}, ${property.city}, ${property.state}`
: property.title || "Property";
const tenantName = tenant?.name || "Tenant";
await createMessage(
lease.id,
user_id,
invite.landlord_id,
"notice",
"Tenant requesting lease approval",
`${tenantName} is requesting a lease application for ${propertyAddress}. Please review the request and send the lease documents when ready.`,
null,
null,
);
await sendLeaseApprovalRequest(invite.landlord_id, {
lease_id: lease.id,
property_address: property.address_1
? `${property.address_1}, ${property.city}, ${property.state}`
: property.title || "Property",
tenant_name: tenant?.name || "Tenant",
property_address: propertyAddress,
tenant_name: tenantName,
landlord_name: landlord?.name || "Landlord",
monthly_rent: property.monthly_rent ?? undefined,
});

View File

@ -141,6 +141,7 @@ export const handler = define.handlers({
if (!invite && propertyId) {
const created = await createTenantInvite(user_id, propertyId, {
availableOn,
invitedEmails: invitedEmails.length > 0 ? invitedEmails : undefined,
});
invite = created[0] || null;
} else if (!invite && !propertyId) {

View File

@ -3,6 +3,7 @@ import { define } from "../../../utils.ts";
import { page } from "fresh";
import { SessionCookie } from "../../auth/callback.ts";
import { createPublicId } from "../../../helpers.ts";
import ConfirmSubmitGuard from "../../../islands/ConfirmSubmitGuard.tsx";
interface PendingApproval {
id: string;
@ -151,6 +152,8 @@ export default define.page(
const { approvals, error } = data;
return (
<>
<ConfirmSubmitGuard />
<div class="min-h-screen bg-gray-50 landlord-route">
<div class="bg-white shadow">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@ -240,7 +243,8 @@ export default define.page(
</div>
<div class="mt-2 flex items-center text-sm text-gray-500 sm:mt-0">
<p>
Monthly Rent: ${approval.property?.monthly_rent
Monthly Rent:
${approval.property?.monthly_rent
? `$${approval.property.monthly_rent.toLocaleString()}/mo`
: "N/A"}
</p>
@ -259,7 +263,12 @@ export default define.page(
{approval.approval_status === "pending" && (
<div class="mt-4 space-y-3">
<div class="flex space-x-2">
<form method="POST" class="inline">
<form
method="POST"
class="inline"
data-confirm-message="Are you sure you want to approve this lease? This will make the lease active and the tenant will be able to access the property."
data-confirm-label="Approve"
>
<input
type="hidden"
name="lease_id"
@ -277,7 +286,12 @@ export default define.page(
Approve
</button>
</form>
<form method="POST" class="inline">
<form
method="POST"
class="inline"
data-confirm-message="Are you sure you want to reject this lease request?"
data-confirm-label="Reject"
>
<input
type="hidden"
name="lease_id"
@ -297,7 +311,12 @@ export default define.page(
</form>
</div>
<form method="POST" class="mt-2">
<form
method="POST"
class="mt-2"
data-confirm-message="Are you sure you want to reject this lease request?"
data-confirm-label="Reject with Reason"
>
<input
type="hidden"
name="lease_id"
@ -396,6 +415,7 @@ export default define.page(
)}
</div>
</div>
</>
);
},
);

View File

@ -4,13 +4,14 @@ import {
getLandlordDashboardData,
getPendingLeaseApprovals,
} from "../../../services/landlordService.ts";
import { DashboardTenantApplication } from "../../../services/tenantApplicationService.ts";
import { getUnreadCount } from "../../../services/messageService.ts";
import { DashboardMaintenanceRequest } from "../../../services/maintenanceRequestService.ts";
import { DashboardMessage } from "../../../services/messageService.ts";
import { page } from "fresh";
import { SessionCookie } from "../../auth/callback.ts";
import { MakeNewDate } from "../../../helpers.ts";
import { Lease, Property, User } from "../../../db/interfaces.ts";
import LandlordPendingApprovals from "../../../islands/LandlordPendingApprovals.tsx";
// Interface for pending lease approval with nested property and tenant data
interface PendingLeaseApproval extends Lease {
@ -51,11 +52,11 @@ interface FlexibleDashboardData {
overdue: number;
}[];
// Additional UI properties with proper types from service layer
tenantApplications?: DashboardTenantApplication[];
maintenanceRequests?: DashboardMaintenanceRequest[];
recentMessages?: DashboardMessage[];
monthlyRevenue?: number;
pendingLeaseApprovals?: PendingLeaseApproval[];
unreadMessageCount?: number;
[key: string]: unknown; // Allow any additional properties
}
@ -79,12 +80,14 @@ export const handler = define.handlers({
try {
const dashboardData = await getLandlordDashboardData(user_id);
const pendingApprovals = await getPendingLeaseApprovals(user_id);
const unreadMessageCount = await getUnreadCount(user_id);
return page({
stateData,
dashboardData: {
...dashboardData,
pendingApprovals,
pendingLeaseApprovals: pendingApprovals,
unreadMessageCount,
},
});
} catch (error) {
@ -103,16 +106,11 @@ export default define.page(
if (error) {
return (
<div class="min-h-screen bg-gray-50 flex items-center justify-center landlord-route">
<div class="text-center">
<h1 class="text-2xl font-bold text-gray-900 mb-4">
Dashboard Error
</h1>
<p class="text-gray-600">{error}</p>
<a
href="/onboarding#landlord"
class="mt-4 inline-block text-blue-600 hover:text-blue-500"
>
<div class="dashboard-error-page landlord-route">
<div class="dashboard-error-inner">
<h1 class="dashboard-error-title">Dashboard Error</h1>
<p class="dashboard-error-text">{error}</p>
<a href="/onboarding#landlord" class="dashboard-error-link">
Return to Onboarding
</a>
</div>
@ -122,54 +120,62 @@ export default define.page(
const {
properties,
tenantApplications,
maintenanceRequests,
recentMessages,
monthlyRevenue,
pendingLeaseApprovals,
unreadMessageCount = 0,
} = dashboardData || {};
return (
<div class="min-h-screen bg-gray-50 landlord-route">
<div class="dashboard-page landlord-route">
{/* Header */}
<div class="bg-white shadow">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center py-6">
<div class="dashboard-header">
<div class="dashboard-header-inner">
<div class="dashboard-header-row">
<div>
<h1 class="text-3xl font-bold text-gray-900">
Landlord Dashboard
</h1>
<p class="mt-1 text-sm text-gray-500">
<h1 class="dashboard-header-title">Landlord Dashboard</h1>
<p class="dashboard-header-subtitle">
Welcome back! Here's what's happening with your properties.
</p>
</div>
<div class="flex space-x-3">
<div class="dashboard-header-actions">
<a
href="/landlord/properties/add"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
class="dashboard-btn dashboard-btn--primary"
>
Add Property
</a>
<a
href="/landlord/tenants/invite"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
class="dashboard-btn dashboard-btn--secondary"
>
Invite Tenant
</a>
<a
href="/landlord/messages"
class={`dashboard-btn ${
unreadMessageCount > 0
? "dashboard-btn--messages-new"
: "dashboard-btn--secondary"
}`}
>
Messages
</a>
</div>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="dashboard-content">
{/* Stats Overview */}
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8">
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="dashboard-stats-grid">
<div class="dashboard-card">
<div class="dashboard-card-body">
<div class="dashboard-stat-card">
<div class="dashboard-stat-icon-wrap">
<svg
class="h-6 w-6 text-gray-400"
class="dashboard-stat-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -182,12 +188,10 @@ export default define.page(
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<div class="dashboard-stat-content">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
Total Properties
</dt>
<dd class="text-lg font-medium text-gray-900">
<dt class="dashboard-stat-label">Total Properties</dt>
<dd class="dashboard-stat-value">
{properties?.length || 0}
</dd>
</dl>
@ -196,12 +200,12 @@ export default define.page(
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="dashboard-card">
<div class="dashboard-card-body">
<div class="dashboard-stat-card">
<div class="dashboard-stat-icon-wrap">
<svg
class="h-6 w-6 text-gray-400"
class="dashboard-stat-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -210,19 +214,15 @@ export default define.page(
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<div class="dashboard-stat-content">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
Pending Applications
</dt>
<dd class="text-lg font-medium text-gray-900">
{tenantApplications?.filter((app) =>
app.status === "pending"
).length || 0}
<dt class="dashboard-stat-label">Pending Approvals</dt>
<dd class="dashboard-stat-value">
{pendingLeaseApprovals?.length || 0}
</dd>
</dl>
</div>
@ -230,12 +230,12 @@ export default define.page(
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="dashboard-card">
<div class="dashboard-card-body">
<div class="dashboard-stat-card">
<div class="dashboard-stat-icon-wrap">
<svg
class="h-6 w-6 text-gray-400"
class="dashboard-stat-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -248,12 +248,10 @@ export default define.page(
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<div class="dashboard-stat-content">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
Open Maintenance
</dt>
<dd class="text-lg font-medium text-gray-900">
<dt class="dashboard-stat-label">Open Maintenance</dt>
<dd class="dashboard-stat-value">
{maintenanceRequests?.filter((req) =>
req.status === "open"
).length || 0}
@ -264,12 +262,12 @@ export default define.page(
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="dashboard-card">
<div class="dashboard-card-body">
<div class="dashboard-stat-card">
<div class="dashboard-stat-icon-wrap">
<svg
class="h-6 w-6 text-gray-400"
class="dashboard-stat-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -282,12 +280,10 @@ export default define.page(
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<div class="dashboard-stat-content">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
Monthly Revenue
</dt>
<dd class="text-lg font-medium text-gray-900">
<dt class="dashboard-stat-label">Monthly Revenue</dt>
<dd class="dashboard-stat-value">
${monthlyRevenue?.toLocaleString() || "0"}
</dd>
</dl>
@ -297,57 +293,55 @@ export default define.page(
</div>
</div>
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2">
<div class="dashboard-two-col">
{/* Recent Properties */}
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg leading-6 font-medium text-gray-900">
Your Properties
</h3>
<a
href="/landlord/properties"
class="text-sm text-blue-600 hover:text-blue-500"
>
<div class="dashboard-card dashboard-card--no-overflow">
<div class="dashboard-card-body">
<div class="dashboard-section-header">
<h3 class="dashboard-section-title">Your Properties</h3>
<a href="/landlord/properties" class="dashboard-section-link">
View all
</a>
</div>
<div class="space-y-4">
<div class="dashboard-list-spaced">
{properties && properties.length > 0
? (
properties.slice(0, 3).map((property) => (
<div
key={property.id}
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div key={property.id} class="dashboard-row">
<div>
<p class="text-sm font-medium text-gray-900">
<p class="dashboard-row-label">
{property.address_1}
</p>
<p class="text-sm text-gray-500">
<p class="dashboard-row-muted">
{property.city}, {property.state}
</p>
<p class="text-xs text-gray-400 capitalize">
<p class="dashboard-row-meta capitalize">
{property.rental_status}
</p>
</div>
<div class="text-right">
<p class="text-sm font-medium text-gray-900">
<div style="text-align: right;">
<p class="dashboard-row-value">
${property.monthly_rent}/mo
</p>
<p class="text-xs text-gray-500">
<p class="dashboard-row-sub">
{property.bedrooms}bd/{property.bathrooms}ba
</p>
<a
href={`/landlord/properties/${property.id}`}
class="dashboard-btn-link"
>
Details
</a>
</div>
</div>
))
)
: (
<div class="text-center py-4">
<p class="text-sm text-gray-500">No properties yet</p>
<div class="dashboard-empty">
<p class="dashboard-empty-text">No properties yet</p>
<a
href="/landlord/properties/add"
class="text-sm text-blue-600 hover:text-blue-500"
class="dashboard-empty-link"
>
Add your first property
</a>
@ -358,42 +352,32 @@ export default define.page(
</div>
{/* Recent Messages */}
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg leading-6 font-medium text-gray-900">
Recent Messages
</h3>
<a
href="/landlord/messages"
class="text-sm text-blue-600 hover:text-blue-500"
>
<div class="dashboard-card dashboard-card--no-overflow">
<div class="dashboard-card-body">
<div class="dashboard-section-header">
<h3 class="dashboard-section-title">Recent Messages</h3>
<a href="/landlord/messages" class="dashboard-section-link">
View all
</a>
</div>
<div class="space-y-4">
<div class="dashboard-list-spaced">
{recentMessages && recentMessages.length > 0
? (
recentMessages.slice(0, 3).map((message) => (
<div
key={message.id}
class="flex items-start space-x-3"
>
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-gray-300 rounded-full flex items-center justify-center">
<span class="text-xs font-medium text-gray-700">
<div key={message.id} class="dashboard-message-row">
<div class="dashboard-message-avatar">
<span class="dashboard-message-avatar-text">
{message.sender_name?.charAt(0) || "T"}
</span>
</div>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900">
<div class="dashboard-message-body">
<p class="dashboard-message-from">
{message.sender_name || "Tenant"}
</p>
<p class="text-sm text-gray-500 truncate">
<p class="dashboard-message-preview">
{message.subject}
</p>
<p class="text-xs text-gray-400">
<p class="dashboard-message-meta">
{MakeNewDate(
(message as DashboardMessage)
.created_on as number,
@ -402,86 +386,18 @@ export default define.page(
</p>
</div>
{!message.is_read && (
<div class="flex-shrink-0">
<div class="w-2 h-2 bg-blue-600 rounded-full">
</div>
</div>
)}
</div>
))
)
: (
<div class="text-center py-4">
<p class="text-sm text-gray-500">No messages yet</p>
</div>
)}
</div>
</div>
</div>
{/* Pending Applications */}
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg leading-6 font-medium text-gray-900">
Pending Applications
</h3>
<a
href="/landlord/applications"
class="text-sm text-blue-600 hover:text-blue-500"
>
View all
</a>
</div>
<div class="space-y-4">
{tenantApplications &&
tenantApplications.filter((app) =>
app.status === "pending"
).length > 0
? (
tenantApplications.filter((app) =>
app.status === "pending"
).slice(0, 3).map((application) => (
<div
key={application.id}
class="flex items-center justify-between p-3 bg-yellow-50 rounded-lg"
class="dashboard-message-dot"
aria-hidden="true"
>
<div>
<p class="text-sm font-medium text-gray-900">
{application.applicant_name}
</p>
<p class="text-sm text-gray-500">
{application.applicant_email}
</p>
<p class="text-xs text-gray-400">
Applied {application.submitted_on
? new Date(application.submitted_on)
.toLocaleDateString()
: "Unknown date"}
</p>
</div>
<div class="flex space-x-2">
<button
type="button"
class="text-xs bg-green-100 text-green-800 px-2 py-1 rounded"
>
Approve
</button>
<button
type="button"
class="text-xs bg-red-100 text-red-800 px-2 py-1 rounded"
>
Reject
</button>
</div>
)}
</div>
))
)
: (
<div class="text-center py-4">
<p class="text-sm text-gray-500">
No pending applications
</p>
<div class="dashboard-empty">
<p class="dashboard-empty-text">No messages yet</p>
</div>
)}
</div>
@ -489,102 +405,25 @@ export default define.page(
</div>
{/* Pending Lease Approvals */}
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg leading-6 font-medium text-gray-900">
Pending Lease Approvals
</h3>
<a
href="/landlord/approvals"
class="text-sm text-blue-600 hover:text-blue-500"
>
View all
</a>
</div>
<div class="space-y-4">
{pendingLeaseApprovals && pendingLeaseApprovals.length > 0
? (
pendingLeaseApprovals.slice(0, 3).map((approval) => (
<div
key={approval.id}
class="flex items-center justify-between p-3 bg-blue-50 rounded-lg"
>
<div>
<p class="text-sm font-medium text-gray-900">
{approval.property?.title ||
approval.property_address || "Property"}
</p>
<p class="text-sm text-gray-500">
Tenant: {approval.tenant?.name ||
approval.tenant?.email || "Unknown"}
</p>
<p class="text-xs text-gray-400">
Requested {approval.approval_requested_at
? new Date(
approval.approval_requested_at as number,
)
.toLocaleDateString()
: "Unknown date"}
</p>
</div>
<div class="flex space-x-2">
<form method="POST" action="/api/lease/approve">
<input
type="hidden"
name="lease_id"
value={approval.id as string}
<LandlordPendingApprovals
approvals={pendingLeaseApprovals ?? []}
/>
<button
type="submit"
class="text-xs bg-green-100 text-green-800 px-2 py-1 rounded hover:bg-green-200"
>
Approve
</button>
</form>
<form method="POST" action="/api/lease/reject">
<input
type="hidden"
name="lease_id"
value={approval.id as string}
/>
<button
type="submit"
class="text-xs bg-red-100 text-red-800 px-2 py-1 rounded hover:bg-red-200"
>
Reject
</button>
</form>
</div>
</div>
))
)
: (
<div class="text-center py-4">
<p class="text-sm text-gray-500">
No pending lease approvals
</p>
</div>
)}
</div>
</div>
</div>
{/* Maintenance Requests */}
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg leading-6 font-medium text-gray-900">
<div class="dashboard-card dashboard-card--no-overflow">
<div class="dashboard-card-body">
<div class="dashboard-section-header">
<h3 class="dashboard-section-title">
Maintenance Requests
</h3>
<a
href="/landlord/maintenance"
class="text-sm text-blue-600 hover:text-blue-500"
class="dashboard-section-link"
>
View all
</a>
</div>
<div class="space-y-4">
<div class="dashboard-list-spaced">
{maintenanceRequests &&
maintenanceRequests.filter((req) => req.status === "open")
.length > 0
@ -593,16 +432,16 @@ export default define.page(
.slice(0, 3).map((request) => (
<div
key={request.id}
class="flex items-center justify-between p-3 bg-red-50 rounded-lg"
class="dashboard-row dashboard-row--red"
>
<div>
<p class="text-sm font-medium text-gray-900">
<p class="dashboard-row-label">
{request.description}
</p>
<p class="text-sm text-gray-500 capitalize">
<p class="dashboard-row-muted capitalize">
Priority: {request.priority}
</p>
<p class="text-xs text-gray-400">
<p class="dashboard-row-meta">
{request.created_on
? new Date(request.created_on)
.toLocaleDateString()
@ -610,7 +449,7 @@ export default define.page(
</p>
</div>
<div>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
<span class="dashboard-badge dashboard-badge--red-pill">
{request.status}
</span>
</div>
@ -618,8 +457,8 @@ export default define.page(
))
)
: (
<div class="text-center py-4">
<p class="text-sm text-gray-500">
<div class="dashboard-empty">
<p class="dashboard-empty-text">
No open maintenance requests
</p>
</div>

View File

@ -0,0 +1,223 @@
// routes/landlord/messages/[id]/index.tsx
import { define } from "../../../../utils.ts";
import { page } from "fresh";
import {
getMessageThread,
markThreadAsRead,
} from "../../../../services/messageService.ts";
import { Message } from "../../../../db/interfaces.ts";
import { AlertMessage } from "../../../tenant/maintenance/new/components/AlertMessage.tsx";
interface MessageThreadPageData {
thread: Message[];
error?: string;
currentUserId: string;
rootMessageId: string;
}
export const handler = define.handlers({
async GET(ctx) {
const { user_id } = ctx.state.data;
const messageId = ctx.params.id;
try {
const thread = await getMessageThread(messageId);
if (!thread || thread.length === 0) {
return page<MessageThreadPageData>({
thread: [],
error: "Message thread not found.",
currentUserId: user_id,
rootMessageId: messageId,
});
}
const rootMessageId = thread[0].parent_message_id || thread[0].id;
await markThreadAsRead(user_id, rootMessageId);
return page<MessageThreadPageData>({
thread,
currentUserId: user_id,
rootMessageId,
});
} catch (err) {
console.error("Error loading message thread:", err);
return page<MessageThreadPageData>({
thread: [],
error: "Failed to load message thread.",
currentUserId: user_id,
rootMessageId: messageId,
});
}
},
});
function MessageBubble(
{ message, isOutgoing }: { message: Message; isOutgoing: boolean },
) {
const bubbleClass = isOutgoing
? "bg-blue-500 text-white self-end rounded-br-none"
: "bg-gray-200 text-gray-800 self-start rounded-tl-none";
const alignmentClass = isOutgoing ? "justify-end" : "justify-start";
const date = message.created_on
? new Date(message.created_on).toLocaleString()
: "Unknown Date";
const attachments = Array.isArray(message.attachments_json)
? message.attachments_json
: message.attachments_json
? JSON.parse(message.attachments_json as unknown as string)
: [];
const content = message.content_encrypted || message.content || "";
return (
<div class={`flex ${alignmentClass} mb-4`}>
<div class={`max-w-3/4 p-4 rounded-lg shadow-md ${bubbleClass}`}>
<p class="text-xs opacity-80 mb-1">
{isOutgoing ? "You" : "Tenant"} - {date}
</p>
<p class="text-sm whitespace-pre-wrap">{content}</p>
{attachments.length > 0 && (
<div class="mt-3 pt-3 border-t border-opacity-30">
<p class="text-xs font-semibold mb-1">Attachments:</p>
<ul class="space-y-1">
{attachments.map((att: { url: string; name: string }) => (
<li key={att.url}>
<a
href={att.url}
target="_blank"
rel="noopener noreferrer"
class="text-xs underline hover:text-opacity-80"
>
{att.name}
</a>
</li>
))}
</ul>
</div>
)}
</div>
</div>
);
}
export default define.page(function LandlordMessageThreadPage({ data }) {
const { thread, error, currentUserId, rootMessageId } =
data as MessageThreadPageData;
const rootMessage = thread[0];
const subject = rootMessage?.subject || "Conversation";
const replyRecipientId = rootMessage
? (rootMessage.sender_id === currentUserId
? rootMessage.recipient_id
: rootMessage.sender_id)
: null;
const replySubject = rootMessage?.subject
? `Re: ${rootMessage.subject}`
: "Re: Message";
return (
<div class="min-h-screen bg-gray-50 landlord-route">
<div class="bg-white shadow">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-6 flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-gray-900">Message Thread</h1>
<p class="mt-1 text-sm text-gray-500">Subject: {subject}</p>
</div>
<div class="flex space-x-3">
<a
href="/landlord/messages"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 shadow-sm"
>
All messages
</a>
<a
href="/landlord/dashboard"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 shadow-sm"
>
Dashboard
</a>
</div>
</div>
</div>
</div>
<div class="max-w-4xl mx-auto sm:px-6 lg:px-8 py-8">
{error && <AlertMessage type="error">{error}</AlertMessage>}
<div class="flex flex-col space-y-4 p-6 bg-white shadow overflow-hidden sm:rounded-lg">
{thread.map((message) => (
<MessageBubble
key={message.id}
message={message}
isOutgoing={message.sender_id === currentUserId}
/>
))}
</div>
{replyRecipientId && (
<div class="mt-8 p-6 bg-white shadow overflow-hidden sm:rounded-lg">
<h2 class="text-lg font-semibold text-gray-900 mb-4">
Reply to thread
</h2>
<form
method="POST"
action="/api/create/message"
class="space-y-4"
>
<input
type="hidden"
name="sender"
value={`/landlord/messages/${rootMessageId}`}
/>
<input
type="hidden"
name="lease_id"
value={rootMessage?.lease_id ?? ""}
/>
<input
type="hidden"
name="recipient_id"
value={replyRecipientId}
/>
<input
type="hidden"
name="message_type"
value={rootMessage?.message_type ?? "general"}
/>
<input type="hidden" name="subject" value={replySubject} />
<div>
<label
for="reply-content"
class="block text-sm font-medium text-gray-700"
>
Your reply
</label>
<textarea
id="reply-content"
name="content"
rows={4}
required
placeholder="Type your reply..."
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
/>
</div>
<div class="flex justify-end">
<button
type="submit"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Send reply
</button>
</div>
</form>
</div>
)}
</div>
</div>
);
});

View File

@ -4,8 +4,10 @@ import { page } from "fresh";
import { SessionCookie } from "../../auth/callback.ts";
import {
deleteMessagesForUser,
getMessagesForUser,
MessagePage,
getMessageThread,
getThreadsForUser,
ThreadPage,
ThreadSummary,
} from "../../../services/messageService.ts";
import LandlordMessageSelection from "../../../islands/LandlordMessageSelection.tsx";
import LandlordMessageThreads from "../../../islands/LandlordMessageThreads.tsx";
@ -26,7 +28,7 @@ interface Filters {
interface MessagesPageData {
stateData: SessionCookie;
messagePage: MessagePage;
threadPage: ThreadPage;
filters: Filters;
error?: string;
}
@ -103,7 +105,7 @@ export const handler = define.handlers({
);
const search = url.searchParams.get("q")?.trim() || "";
const messagePage = await getMessagesForUser(user_id, {
const threadPage = await getThreadsForUser(user_id, {
page: pageNumber,
pageSize,
sort,
@ -115,7 +117,7 @@ export const handler = define.handlers({
return page({
stateData,
messagePage,
threadPage,
filters: {
status,
type,
@ -129,12 +131,12 @@ export const handler = define.handlers({
console.error("Error loading messages:", error);
return page({
stateData,
messagePage: {
messages: [],
threadPage: {
threads: [],
page: 1,
pageSize: 10,
total: 0,
totalMessages: 0,
totalThreads: 0,
totalPages: 1,
unread: 0,
},
@ -168,12 +170,25 @@ export const handler = define.handlers({
return Response.redirect(redirectUrl, 303);
}
const ids = formData.getAll("message_ids")
const rootIds = formData.getAll("message_ids")
.map((value) => value.toString())
.filter(Boolean);
if (ids.length > 0) {
await deleteMessagesForUser(user_id, ids);
if (rootIds.length > 0) {
const messageIds: string[] = [];
for (const rootId of rootIds) {
const thread = await getMessageThread(rootId);
if (thread) {
for (const m of thread) {
if (m.sender_id === user_id || m.recipient_id === user_id) {
messageIds.push(m.id);
}
}
}
}
if (messageIds.length > 0) {
await deleteMessagesForUser(user_id, messageIds);
}
}
return Response.redirect(redirectUrl, 303);
@ -186,12 +201,86 @@ export const handler = define.handlers({
});
export default define.page(function LandlordMessages({ data }) {
const { stateData, messagePage, filters, error } = data as MessagesPageData;
const { messages, page, pageSize, total, totalPages } = messagePage;
const { stateData, threadPage, filters, error } = data as MessagesPageData;
const { threads, page, pageSize, total, totalPages } = threadPage;
const totalThreads = threadPage.totalThreads ?? total;
const unread = threadPage.unread ?? 0;
const startIndex = total === 0 ? 0 : (page - 1) * pageSize + 1;
const endIndex = Math.min(total, page * pageSize);
const returnTo = buildPageUrl(filters, page);
const hasMessages = messages.length > 0;
const hasThreads = threads.length > 0;
const ThreadRow = ({ thread }: { thread: ThreadSummary }) => {
const msg = thread.latestMessage;
const isOutbound = msg.sender_id === stateData.user_id;
const counterpart = isOutbound
? (msg.recipient_name ?? "Recipient")
: (msg.sender_name ?? "Tenant");
const contentPreview = (msg.content_encrypted || msg.content || "")
.substring(0, 100);
return (
<li
class={`p-4 border-b border-gray-200 transition-colors ${
thread.hasUnread
? "bg-blue-50 hover:bg-blue-100"
: "bg-white hover:bg-gray-50"
}`}
>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<input
type="checkbox"
name="message_ids"
value={thread.rootMessageId}
form="bulk-delete-form"
class="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<div class="min-w-0 flex-1">
<div class="text-sm font-medium text-gray-900 flex items-center gap-2 flex-wrap">
{thread.hasUnread && (
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-blue-600 text-white">
New!
</span>
)}
{thread.subject}
{thread.messageCount > 1 && (
<span class="text-xs text-gray-500">
({thread.messageCount} messages)
</span>
)}
</div>
<p class="text-sm text-gray-500 truncate">
<span class="font-semibold text-gray-700">
{isOutbound ? "To" : "From"} {counterpart}:
</span>{" "}
{contentPreview || "No content preview"}
{contentPreview.length >= 100 ? "..." : ""}
</p>
{msg.property_address && (
<p class="text-xs text-gray-400 mt-0.5">
Property: {msg.property_address}
</p>
)}
</div>
</div>
<div class="flex items-center space-x-4">
<div class="text-sm text-gray-500">
{msg.created_on
? new Date(msg.created_on).toLocaleDateString()
: "—"}
</div>
<a
href={`/landlord/messages/${thread.rootMessageId}`}
class="text-sm text-blue-600 hover:text-blue-500"
>
View Thread
</a>
</div>
</div>
</li>
);
};
return (
<div class="min-h-screen bg-gray-50 landlord-route">
@ -208,10 +297,16 @@ export default define.page(function LandlordMessages({ data }) {
</div>
<div class="flex flex-wrap gap-3">
<a
href="/landlord/dashboard"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
href="/landlord/messages/new"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 shadow-sm"
>
Back to Dashboard
New Message
</a>
<a
href="/landlord/dashboard"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 shadow-sm"
>
Dashboard
</a>
</div>
</div>
@ -225,7 +320,7 @@ export default define.page(function LandlordMessages({ data }) {
</div>
)}
<form method="GET" class="bg-white shadow rounded-lg p-6 mb-8">
<form method="GET" class="bg-white shadow rounded-lg p-6 mb-6">
<div class="grid grid-cols-1 gap-4 lg:grid-cols-5">
<div class="lg:col-span-2">
<label class="block text-sm font-medium text-gray-700">
@ -339,14 +434,14 @@ export default define.page(function LandlordMessages({ data }) {
</form>
<div
class="bg-white shadow rounded-lg"
class="bg-white shadow overflow-hidden sm:rounded-lg"
data-message-form
data-status-filter={filters.status}
>
<form
method="POST"
id="bulk-delete-form"
class="px-4 py-5 sm:px-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
class="px-4 py-5 sm:px-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between border-b border-gray-200"
>
<input type="hidden" name="return_to" value={returnTo} />
<div>
@ -354,7 +449,12 @@ export default define.page(function LandlordMessages({ data }) {
All Messages
</h2>
<p class="text-sm text-gray-500">
showing {startIndex}-{endIndex} of {total} messages
Showing {startIndex}-{endIndex} of {totalThreads} threads
{unread > 0 && (
<span class="ml-2 text-blue-600 font-semibold">
({unread} unread)
</span>
)}
</p>
</div>
<div class="flex flex-wrap items-center gap-3 text-sm text-gray-500">
@ -363,7 +463,7 @@ export default define.page(function LandlordMessages({ data }) {
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
data-select-all
disabled={!hasMessages}
disabled={!hasThreads}
/>
<span>Select all</span>
</label>
@ -383,204 +483,20 @@ export default define.page(function LandlordMessages({ data }) {
</div>
</form>
{messages.length === 0
{threads.length === 0
? (
<div class="px-6 py-12 text-center text-sm text-gray-500">
No messages match the selected filters.
<div class="px-6 py-12 text-center">
<p class="text-sm text-gray-500">
No threads match the selected filters.
</p>
</div>
)
: (
<div class="divide-y divide-gray-200">
{messages.map((message) => {
const isOutbound = message.sender_id === stateData.user_id;
const counterpart = isOutbound
? message.recipient_name || "Recipient"
: message.sender_name || "Tenant";
const replyRecipientId = isOutbound
? message.recipient_id
: message.sender_id;
const replySubject = message.subject
? `Re: ${message.subject}`
: "Re: Message";
const canReply = Boolean(replyRecipientId);
const isUnreadInbound = !isOutbound &&
message.recipient_id === stateData.user_id &&
!message.is_read;
const subjectClass = isUnreadInbound
? "mt-2 text-sm font-semibold text-gray-900"
: "mt-2 text-sm font-medium text-gray-700";
const previewClass = isUnreadInbound
? "mt-1 text-sm text-gray-700 line-clamp-2"
: "mt-1 text-sm text-gray-500 line-clamp-2";
const panelId = `message-panel-${message.id}`;
return (
<div
key={message.id}
class="px-6 py-5"
data-message-row
data-message-status={isUnreadInbound ? "unread" : "read"}
>
<div class="flex items-start gap-4">
<input
type="checkbox"
name="message_ids"
value={message.id}
form="bulk-delete-form"
class="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
data-message-checkbox
aria-label={`Select message ${
message.subject || "No subject"
}`}
/>
<div class="flex-1">
<button
type="button"
class="w-full text-left rounded-lg p-2 -m-2 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500"
data-message-toggle
data-message-id={message.id}
data-message-unread={isUnreadInbound
? "true"
: "false"}
aria-expanded="false"
aria-controls={panelId}
>
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="text-xs uppercase tracking-wide text-gray-400">
{isOutbound ? "To" : "From"}
</span>
<span class="text-sm font-medium text-gray-900">
{counterpart}
</span>
</div>
<p
class={subjectClass}
data-message-subject
>
{message.subject || "No subject"}
</p>
<p
class={previewClass}
data-message-preview
>
{message.content ||
"Message content is unavailable."}
</p>
{message.property_address && (
<p class="mt-2 text-xs text-gray-400">
Property: {message.property_address}
</p>
)}
</div>
<div class="flex flex-col items-start gap-2 text-sm text-gray-500 lg:items-end">
<span>
{message.message_type
? message.message_type.charAt(0)
.toUpperCase() +
message.message_type.slice(1)
: "General"}
</span>
<span class="inline-flex items-center gap-2">
{message.created_on
? new Date(message.created_on)
.toLocaleDateString()
: "Unknown date"}
{isUnreadInbound && (
<span
class="h-2 w-2 rounded-full bg-blue-600"
data-unread-dot
/>
)}
</span>
</div>
</div>
</button>
<div
id={panelId}
class="mt-4 hidden"
data-message-panel
>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<p class="text-sm text-gray-700 whitespace-pre-line">
{message.content ||
"Message content is unavailable."}
</p>
{message.property_address && (
<p class="mt-3 text-xs text-gray-500">
Property: {message.property_address}
</p>
)}
</div>
<div class="mt-4">
<p class="text-sm font-semibold text-gray-900">
Quick reply
</p>
<p class="mt-1 text-xs text-gray-500">
Subject: {replySubject}
</p>
{canReply
? (
<form
method="POST"
action="/api/create/message"
class="mt-3 space-y-3"
>
<input
type="hidden"
name="sender"
value={returnTo}
/>
<input
type="hidden"
name="lease_id"
value={message.lease_id || ""}
/>
<input
type="hidden"
name="recipient_id"
value={replyRecipientId || ""}
/>
<input
type="hidden"
name="message_type"
value={message.message_type || "general"}
/>
<input
type="hidden"
name="subject"
value={replySubject}
/>
<textarea
name="content"
rows={6}
required
placeholder="Type your reply..."
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
/>
<div class="flex justify-end">
<button
type="submit"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
Send reply
</button>
</div>
</form>
)
: (
<p class="mt-3 text-sm text-gray-500">
Reply is unavailable for this message.
</p>
)}
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
<ul class="divide-y divide-gray-200">
{threads.map((thread) => (
<ThreadRow key={thread.rootMessageId} thread={thread} />
))}
</ul>
)}
<div class="px-6 py-4 border-t border-gray-200 flex items-center justify-between text-sm">

View File

@ -0,0 +1,699 @@
// routes/landlord/properties/[id]/index.tsx
// Property details page with complete payment history and expenses
import { define } from "../../../../utils.ts";
import { page } from "fresh";
import { getPropertyById } from "../../../../services/propertyService.ts";
import { getLeasesByProperty } from "../../../../services/leaseService.ts";
import { getUserById } from "../../../../services/userService.ts";
import {
getExpensesByProperty,
type PropertyExpense,
} from "../../../../services/expenseService.ts";
import {
getRentHistory,
type RentChange,
} from "../../../../services/rentChangeService.ts";
import { db } from "../../../../db/db.ts";
import { leases, payment_transactions } from "../../../../db/schema.ts";
import { eq } from "drizzle-orm";
import type { Property, Lease, User } from "../../../../db/interfaces.ts";
interface PropertyDetailsData {
property: Property | null;
leases: Lease[];
payments: Array<{
id: string;
amount: number | null;
payment_date: number | null;
payment_method: string | null;
transaction_status: string | null;
tenant_name?: string;
}>;
expenses: PropertyExpense[];
rentHistory: Array<{
effective_date: number;
monthly_rent: number;
previous_rent: number | null;
reason: string | null;
is_current: boolean;
}>;
error?: string;
}
function buildPropertyAddress(property: Property | null): string {
if (!property) return "";
return [
property.address_1,
property.address_2,
property.city,
property.state,
property.zip,
].filter(Boolean).join(", ");
}
export const handler = define.handlers({
async GET(ctx) {
const { id } = ctx.params;
try {
// Fetch property
const property = await getPropertyById(id);
if (!property) {
return page({
property: null,
leases: [],
payments: [],
expenses: [],
rentHistory: [],
error: "Property not found",
} as PropertyDetailsData);
}
// Fetch all leases for this property
const leases = await getLeasesByProperty(id);
// Fetch all payment transactions for these leases
const payments: PropertyDetailsData["payments"] = [];
for (const lease of leases) {
if (lease.id) {
const leasePayments = await db.select({
id: payment_transactions.id,
amount: payment_transactions.amount,
payment_date: payment_transactions.payment_date,
payment_method: payment_transactions.payment_method,
transaction_status: payment_transactions.transaction_status,
})
.from(payment_transactions)
.where(eq(payment_transactions.lease_id, lease.id));
// Fetch tenant name for the lease
let tenant_name = undefined;
if (lease.tenant_id) {
const tenant = await getUserById(lease.tenant_id);
tenant_name = tenant?.name || undefined;
}
for (const payment of leasePayments) {
payments.push({
...payment,
tenant_name,
});
}
}
}
// Sort payments by date (newest first)
payments.sort((a, b) =>
(b.payment_date || 0) - (a.payment_date || 0)
);
// Fetch all expenses for this property
const expenses = await getExpensesByProperty(id);
// Fetch rent history
const rentHistory = await getRentHistory(id);
return page({
property,
leases,
payments,
expenses,
rentHistory,
} as PropertyDetailsData);
} catch (error) {
console.error("Error loading property details:", error);
return page({
property: null,
leases: [],
payments: [],
expenses: [],
rentHistory: [],
error: "Failed to load property details",
} as PropertyDetailsData);
}
},
});
export default define.page(function PropertyDetailsPage({ data }: { data: PropertyDetailsData }) {
const { property, leases, payments, expenses, rentHistory, error } = data;
if (error) {
return (
<div class="dashboard-page landlord-route">
<div class="dashboard-error-page">
<div class="dashboard-error-inner">
<h1 class="dashboard-error-title">Error</h1>
<p class="dashboard-error-text">{error}</p>
<a href="/landlord/properties" class="dashboard-error-link">
Back to Properties
</a>
</div>
</div>
</div>
);
}
if (!property) {
return (
<div class="dashboard-page landlord-route">
<div class="dashboard-error-page">
<div class="dashboard-error-inner">
<h1 class="dashboard-error-title">Property Not Found</h1>
<p class="dashboard-error-text">The property you're looking for doesn't exist.</p>
<a href="/landlord/properties" class="dashboard-error-link">
Back to Properties
</a>
</div>
</div>
</div>
);
}
const propertyAddress = buildPropertyAddress(property);
const totalPayments = payments.reduce((sum, p) => sum + (p.amount || 0), 0);
const totalExpenses = expenses.reduce((sum, e) => sum + (e.amount || 0), 0);
const netIncome = totalPayments - totalExpenses;
// Calculate expenses by category
const expensesByCategory: Record<string, number> = {};
for (const expense of expenses) {
const category = expense.category || "uncategorized";
expensesByCategory[category] = (expensesByCategory[category] || 0) + (expense.amount || 0);
}
return (
<div class="dashboard-page landlord-route">
{/* Header */}
<div class="dashboard-header">
<div class="dashboard-header-inner">
<div class="dashboard-header-row">
<div>
<h1 class="dashboard-header-title">Property Details</h1>
<p class="dashboard-header-subtitle">
{property.title || propertyAddress}
</p>
</div>
<div class="dashboard-header-actions">
<a
href="/landlord/properties"
class="dashboard-btn dashboard-btn--secondary"
>
Back to Properties
</a>
<a
href={`/landlord/properties/${property.id}/edit`}
class="dashboard-btn dashboard-btn--primary"
>
Edit Property
</a>
</div>
</div>
</div>
</div>
<div class="dashboard-content">
{/* Property Info Card */}
<div class="dashboard-card dashboard-section-mb">
<div class="dashboard-card-body">
<h3 class="dashboard-section-title dashboard-section-title--mb">
Property Information
</h3>
<div class="dashboard-property-grid">
<div>
<dt class="dashboard-stat-label">Address</dt>
<dd class="dashboard-stat-value">
{propertyAddress || "N/A"}
</dd>
</div>
<div>
<dt class="dashboard-stat-label">Monthly Rent</dt>
<dd class="dashboard-stat-value">
${property.monthly_rent?.toLocaleString() || "N/A"}
</dd>
</div>
<div>
<dt class="dashboard-stat-label">Status</dt>
<dd>
<span class={`dashboard-badge capitalize ${
property.rental_status === "occupied"
? "dashboard-badge--green"
: property.rental_status === "available"
? "dashboard-badge--blue"
: "dashboard-badge--yellow"
}`}>
{property.rental_status || "unknown"}
</span>
</dd>
</div>
<div>
<dt class="dashboard-stat-label">Bedrooms / Bathrooms</dt>
<dd class="dashboard-stat-value">
{property.bedrooms || 0}bd / {property.bathrooms || 0}ba
</dd>
</div>
<div>
<dt class="dashboard-stat-label">Square Footage</dt>
<dd class="dashboard-stat-value">
{property.square_footage?.toLocaleString() || "N/A"} sq ft
</dd>
</div>
<div>
<dt class="dashboard-stat-label">Property Type</dt>
<dd class="dashboard-stat-value capitalize">
{property.property_type || "N/A"}
</dd>
</div>
</div>
</div>
</div>
{/* Financial Summary */}
<div class="dashboard-stats-grid dashboard-section-mb">
<div class="dashboard-card">
<div class="dashboard-card-body">
<div class="dashboard-stat-card">
<div class="dashboard-stat-icon-wrap">
<svg
class="dashboard-stat-icon dashboard-stat-icon--green"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div class="dashboard-stat-content">
<dl>
<dt class="dashboard-stat-label">Total Payments Received</dt>
<dd class="dashboard-stat-value">
${totalPayments.toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="dashboard-card">
<div class="dashboard-card-body">
<div class="dashboard-stat-card">
<div class="dashboard-stat-icon-wrap">
<svg
class="dashboard-stat-icon dashboard-stat-icon--red"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</div>
<div class="dashboard-stat-content">
<dl>
<dt class="dashboard-stat-label">Total Expenses</dt>
<dd class="dashboard-stat-value">
${totalExpenses.toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="dashboard-card">
<div class="dashboard-card-body">
<div class="dashboard-stat-card">
<div class="dashboard-stat-icon-wrap">
<svg
class="dashboard-stat-icon dashboard-stat-icon--blue"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
</div>
<div class="dashboard-stat-content">
<dl>
<dt class="dashboard-stat-label">Net Income</dt>
<dd class={`dashboard-stat-value ${
netIncome >= 0 ? "text-success" : "text-danger"
}`}>
${netIncome.toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="dashboard-card">
<div class="dashboard-card-body">
<div class="dashboard-stat-card">
<div class="dashboard-stat-icon-wrap">
<svg
class="dashboard-stat-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<div class="dashboard-stat-content">
<dl>
<dt class="dashboard-stat-label">Active Leases</dt>
<dd class="dashboard-stat-value">
{leases.filter((l) => l.lease_status === "active").length}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{/* Two column layout */}
<div class="dashboard-two-col">
{/* Payment History */}
<div class="dashboard-card dashboard-card--no-overflow dashboard-card--full-width">
<div class="dashboard-card-body">
<div class="dashboard-section-header">
<h3 class="dashboard-section-title">Payment History</h3>
<span class="dashboard-section-subtitle">
{payments.length} payments received
</span>
</div>
<div class="dashboard-table-wrap">
<table class="dashboard-table">
<thead>
<tr>
<th class="dashboard-table-th">Date</th>
<th class="dashboard-table-th">Tenant</th>
<th class="dashboard-table-th">Method</th>
<th class="dashboard-table-th">Amount</th>
<th class="dashboard-table-th">Status</th>
</tr>
</thead>
<tbody>
{payments && payments.length > 0
? (
payments.map((payment) => (
<tr key={payment.id}>
<td class="dashboard-table-td">
{payment.payment_date
? new Date(payment.payment_date)
.toLocaleDateString()
: "N/A"}
</td>
<td class="dashboard-table-td">
{payment.tenant_name || "Unknown"}
</td>
<td class="dashboard-table-td capitalize">
{payment.payment_method || "N/A"}
</td>
<td class="dashboard-table-td">
${payment.amount?.toLocaleString() || "0"}
</td>
<td class="dashboard-table-td">
<span
class={`dashboard-badge ${
payment.transaction_status === "completed"
? "dashboard-badge--green"
: payment.transaction_status === "pending"
? "dashboard-badge--yellow"
: "dashboard-badge--red"
}`}
>
{payment.transaction_status || "unknown"}
</span>
</td>
</tr>
))
)
: (
<tr>
<td colspan={5} class="dashboard-table-empty">
No payment history yet
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
{/* Expenses by Category */}
{Object.keys(expensesByCategory).length > 0 && (
<div class="dashboard-card">
<div class="dashboard-card-body">
<h3 class="dashboard-section-title dashboard-section-title--mb">
Expenses by Category
</h3>
<div class="dashboard-list-spaced">
{Object.entries(expensesByCategory)
.sort(([, a], [, b]) => b - a)
.map(([category, amount]) => (
<div key={category} class="dashboard-row">
<div>
<p class="dashboard-row-label capitalize">
{category}
</p>
</div>
<span class="dashboard-row-value">
${amount.toLocaleString()}
</span>
</div>
))}
</div>
</div>
</div>
)}
{/* Expense History */}
<div class="dashboard-card dashboard-card--no-overflow dashboard-card--full-width">
<div class="dashboard-card-body">
<div class="dashboard-section-header">
<h3 class="dashboard-section-title">Expense History</h3>
<a
href={`/landlord/properties/${property.id}/expenses/add`}
class="dashboard-section-link"
>
Add Expense
</a>
</div>
<div class="dashboard-table-wrap">
<table class="dashboard-table">
<thead>
<tr>
<th class="dashboard-table-th">Date</th>
<th class="dashboard-table-th">Category</th>
<th class="dashboard-table-th">Description</th>
<th class="dashboard-table-th">Vendor</th>
<th class="dashboard-table-th">Amount</th>
</tr>
</thead>
<tbody>
{expenses && expenses.length > 0
? (
expenses.map((expense) => (
<tr key={expense.id}>
<td class="dashboard-table-td">
{expense.expense_date
? new Date(expense.expense_date)
.toLocaleDateString()
: "N/A"}
</td>
<td class="dashboard-table-td capitalize">
{expense.category || "N/A"}
</td>
<td class="dashboard-table-td">
{expense.description || "-"}
</td>
<td class="dashboard-table-td">
{expense.vendor || "-"}
</td>
<td class="dashboard-table-td">
${expense.amount?.toLocaleString() || "0"}
</td>
</tr>
))
)
: (
<tr>
<td colspan={5} class="dashboard-table-empty">
No expenses recorded yet
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
{/* Active Leases */}
<div class="dashboard-card dashboard-card--no-overflow dashboard-card--full-width">
<div class="dashboard-card-body">
<div class="dashboard-section-header">
<h3 class="dashboard-section-title">Active Leases</h3>
</div>
<div class="dashboard-list-spaced">
{leases && leases.length > 0
? (
leases
.filter((l) => l.lease_status === "active")
.map((lease) => (
<div key={lease.id} class="dashboard-row">
<div>
<p class="dashboard-row-label">
{lease.title || `Lease #${lease.id.slice(0, 8)}`}
</p>
<p class="dashboard-row-muted">
{lease.start_date
? new Date(lease.start_date)
.toLocaleDateString()
: ""}{" "}
-{" "}
{lease.end_date
? new Date(lease.end_date)
.toLocaleDateString()
: ""}
</p>
</div>
<span class="dashboard-row-value">
${lease.rent?.toLocaleString() || "0"}/mo
</span>
</div>
))
)
: (
<div class="dashboard-empty">
<p class="dashboard-empty-text">No active leases</p>
</div>
)}
</div>
</div>
</div>
{/* Rent History */}
<div class="dashboard-card dashboard-card--no-overflow dashboard-card--full-width">
<div class="dashboard-card-body">
<div class="dashboard-section-header">
<h3 class="dashboard-section-title">Rent History</h3>
<a
href={`/landlord/properties/${property.id}/rent/change`}
class="dashboard-section-link"
>
Change Rent
</a>
</div>
<div class="dashboard-table-wrap">
<table class="dashboard-table">
<thead>
<tr>
<th class="dashboard-table-th">Effective Date</th>
<th class="dashboard-table-th">Previous Rent</th>
<th class="dashboard-table-th">New Rent</th>
<th class="dashboard-table-th">Change</th>
<th class="dashboard-table-th">Reason</th>
</tr>
</thead>
<tbody>
{rentHistory && rentHistory.length > 0
? (
rentHistory.map((entry, index) => {
const previousRent = entry.previous_rent || 0;
const newRent = entry.monthly_rent || 0;
const changeAmount = newRent - previousRent;
const isIncrease = changeAmount > 0;
const isDecrease = changeAmount < 0;
return (
<tr key={index}>
<td class="dashboard-table-td">
{entry.effective_date
? new Date(entry.effective_date)
.toLocaleDateString()
: "N/A"}
{entry.is_current && (
<span class="dashboard-badge dashboard-badge--green" style={{ marginLeft: "8px" }}>
Current
</span>
)}
</td>
<td class="dashboard-table-td">
${previousRent.toLocaleString()}
</td>
<td class="dashboard-table-td">
${newRent.toLocaleString()}
</td>
<td class="dashboard-table-td">
{previousRent > 0 && (
<span
class={
isIncrease
? "text-success"
: isDecrease
? "text-danger"
: ""
}
>
{isIncrease && "+"}
${changeAmount.toLocaleString()}
{changeAmount !== 0 && ` (${
Math.round(
(changeAmount / previousRent) * 100
)
}%)`}
</span>
)}
{previousRent === 0 && <span>-</span>}
</td>
<td class="dashboard-table-td">
{entry.reason || "-"}
</td>
</tr>
);
})
)
: (
<tr>
<td colspan={5} class="dashboard-table-empty">
No rent history available
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
);
});

View File

@ -1,6 +1,8 @@
// routes/tenant/dashboard/index.tsx
import { define } from "../../../utils.ts";
import { sqlGetUser } from "../../../db/sqlUserRepository.ts";
import { getUserById } from "../../../services/userService.ts";
import { getOnboardingStatus } from "../../../services/onboardingService.ts";
import { getLeasesByTenant } from "../../../services/leaseService.ts";
import { getMessagesByUser } from "../../../services/messageService.ts";
import { getMaintenanceRequestsByUser } from "../../../services/maintenanceRequestService.ts";
@ -45,12 +47,21 @@ async function buildInviteNotices(
invites: TenantInvite[],
leases: Lease[],
) {
const leasePropertyIds = new Set(
leases.map((lease) => lease.property_id).filter(Boolean) as string[],
// Only hide invites for properties where the lease is final (approved/active).
// Keep the invite visible while the lease is pending (until "Start Lease" → landlord approval).
const finalLeasePropertyIds = new Set(
(leases || [])
.filter(
(lease) =>
lease.lease_status === "active" ||
lease.approval_status === "approved",
)
.map((lease) => lease.property_id)
.filter(Boolean) as string[],
);
const pendingInvites = invites.filter((invite) => {
const propertyId = invite.property_id || "";
return !propertyId || !leasePropertyIds.has(propertyId);
return !propertyId || !finalLeasePropertyIds.has(propertyId);
});
const notices = await Promise.all(pendingInvites.map(async (invite) => {
const property = invite.property_id
@ -71,17 +82,36 @@ export const handler = define.handlers({
async GET(ctx) {
const { user_id } = ctx.state.data;
const onboardingStatus = await getOnboardingStatus(user_id);
if (!onboardingStatus?.onboarded_tenant) {
return new Response("", {
status: 303,
headers: { Location: "/tenant/onboarding" },
});
}
try {
const user = await getUserById(user_id);
const leases = await getLeasesByTenant(user_id);
const messages = await getMessagesByUser(user_id);
const maintenanceRequests = await getMaintenanceRequestsByUser(user_id);
const recentTransactions = await getPaymentTransactionsByUser(user_id);
const invites = user?.email
? await getTenantInvitesByEmail(user.email)
// Invites are looked up by email; KV user may have no email if OAuth didn't provide it at login.
const emailForInvites = user?.email?.trim() ||
(await sqlGetUser(user_id))?.email?.trim() ||
"";
const invites = emailForInvites
? await getTenantInvitesByEmail(emailForInvites)
: [];
const inviteNotices = await buildInviteNotices(invites, leases || []);
// Fetch property data for the current lease
const currentLease = leases?.[0];
let currentProperty = null;
if (currentLease?.property_id) {
currentProperty = await getPropertyById(currentLease.property_id);
}
return page({
user,
leases,
@ -89,6 +119,7 @@ export const handler = define.handlers({
maintenanceRequests,
recentTransactions,
inviteNotices,
currentProperty,
});
} catch (error) {
console.error("Error loading tenant dashboard:", error);
@ -107,6 +138,7 @@ export default define.page(function TenantDashboard({ data }) {
maintenanceRequests,
recentTransactions,
inviteNotices,
currentProperty,
error,
} = data as {
user: User;
@ -115,23 +147,25 @@ export default define.page(function TenantDashboard({ data }) {
maintenanceRequests: MaintenanceRequest[];
recentTransactions: PaymentTransaction[];
inviteNotices?: InviteNotice[];
currentProperty?: Property | null;
error?: string;
};
if (error) {
return (
<div class="min-h-screen bg-gray-50 tenant-route flex items-center justify-center">
<div class="text-center">
<h1 class="text-2xl font-bold text-gray-900 mb-4">Dashboard Error</h1>
<p class="text-gray-600">{error}</p>
<div class="dashboard-error-page tenant-route">
<div class="dashboard-error-inner">
<h1 class="dashboard-error-title">Dashboard Error</h1>
<p class="dashboard-error-text">{error}</p>
</div>
</div>
);
}
const currentLease = leases?.[0]; // Assuming tenant has one active lease
const unreadMessageCount = messages?.filter((message) => !message.is_read)
.length || 0;
const unreadMessageCount = messages?.filter(
(m) => m.recipient_id === user?.id && !m.is_read,
).length || 0;
const openRequestCount = maintenanceRequests?.filter((request) =>
request.status === "open" || request.status === "in_progress"
).length || 0;
@ -140,72 +174,78 @@ export default define.page(function TenantDashboard({ data }) {
const notificationTotal = unreadMessageCount + openRequestCount + inviteCount;
return (
<div class="min-h-screen bg-gray-50 tenant-route">
<div class="dashboard-page tenant-route">
{/* Header */}
<div class="bg-white shadow">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center py-6">
<div class="dashboard-header">
<div class="dashboard-header-inner">
<div class="dashboard-header-row">
<div>
<h1 class="text-3xl font-bold text-gray-900">Tenant Portal</h1>
<p class="mt-1 text-sm text-gray-500">
<h1 class="dashboard-header-title">Tenant Portal</h1>
<p class="dashboard-header-subtitle">
Welcome back, {user?.name}!
</p>
</div>
<div class="flex space-x-3">
<div class="dashboard-header-actions">
<a
href="/tenant/maintenance/new"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
class="dashboard-btn dashboard-btn--primary"
>
Request Maintenance
</a>
<a
href="/tenant/messages/new"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
class="dashboard-btn dashboard-btn--secondary"
>
Contact Landlord
</a>
<a
href="/tenant/messages"
class={`dashboard-btn ${
unreadMessageCount > 0
? "dashboard-btn--messages-new"
: "dashboard-btn--secondary"
}`}
>
Messages
</a>
</div>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="dashboard-content">
{/* Notifications */}
<div class="bg-white shadow rounded-lg mb-8">
<div class="px-4 py-5 sm:p-6">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="dashboard-card dashboard-card--no-overflow dashboard-section-mb">
<div class="dashboard-card-body">
<div class="dashboard-notification-header">
<div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
<h3 class="dashboard-section-title">
Notification Center
</h3>
<p class="text-sm text-gray-500">
<p class="dashboard-header-subtitle">
Your invites, updates, and action items in one place.
</p>
</div>
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-semibold text-gray-600">
<span class="dashboard-notification-badge">
{notificationTotal} total notifications
</span>
</div>
<div class="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-3">
<div class="dashboard-notification-grid">
<div
class={`rounded-lg border p-4 ${
hasInvites
? "border-amber-200 bg-amber-50"
: "border-gray-200 bg-white"
}`}
class={hasInvites
? "dashboard-notification-card dashboard-notification-card--highlight"
: "dashboard-notification-card"}
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="dashboard-notification-card-inner">
<div class="dashboard-notification-icon-wrap">
<div
class={`flex h-10 w-10 items-center justify-center rounded-full ${
hasInvites
? "bg-amber-100 text-amber-700"
: "bg-gray-100 text-gray-500"
} relative`}
class={hasInvites
? "dashboard-notification-icon dashboard-notification-icon--highlight"
: "dashboard-notification-icon"}
>
<svg
class="h-5 w-5"
class="dashboard-notification-icon-svg"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -218,45 +258,45 @@ export default define.page(function TenantDashboard({ data }) {
/>
</svg>
{hasInvites && (
<span class="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-amber-500 ring-2 ring-amber-50" />
<span
class="dashboard-notification-dot"
aria-hidden="true"
/>
)}
</div>
<div>
<p class="text-sm font-medium text-gray-900">Invites</p>
<p class="text-xs text-gray-500">
<p class="dashboard-row-label">Invites</p>
<p class="dashboard-row-sub">
Pending property invitations
</p>
</div>
</div>
<span
class={`text-sm font-semibold ${
hasInvites ? "text-amber-700" : "text-gray-500"
}`}
class={hasInvites
? "dashboard-notification-count dashboard-notification-count--highlight"
: "dashboard-notification-count"}
>
{inviteCount}
</span>
</div>
<div class="mt-3 space-y-3 text-sm text-gray-700">
<div class="dashboard-invite-list">
{hasInvites
? (
inviteNotices?.slice(0, 2).map((invite) => (
<div
key={invite.id}
class="flex items-start justify-between gap-3 rounded-md bg-white/80 p-2"
>
<div key={invite.id} class="dashboard-invite-item">
<div>
<p class="font-medium text-gray-900">
<p class="dashboard-invite-item-label">
{invite.propertyLabel}
</p>
{invite.propertyAddress && (
<p class="text-xs text-gray-500">
<p class="dashboard-invite-item-address">
{invite.propertyAddress}
</p>
)}
</div>
<a
href={invite.inviteUrl}
class="text-xs font-semibold text-amber-700 hover:text-amber-600"
class="dashboard-invite-link"
>
Review
</a>
@ -264,19 +304,19 @@ export default define.page(function TenantDashboard({ data }) {
))
)
: (
<p class="text-xs text-gray-500">
<p class="dashboard-row-sub">
You have no pending invites.
</p>
)}
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 text-blue-700">
<div class="dashboard-notification-card">
<div class="dashboard-notification-card-inner">
<div class="dashboard-notification-icon-wrap">
<div class="dashboard-notification-icon dashboard-notification-icon--blue">
<svg
class="h-5 w-5"
class="dashboard-notification-icon-svg"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -290,34 +330,27 @@ export default define.page(function TenantDashboard({ data }) {
</svg>
</div>
<div>
<p class="text-sm font-medium text-gray-900">
Messages
</p>
<p class="text-xs text-gray-500">
<p class="dashboard-row-label">Messages</p>
<p class="dashboard-row-sub">
Unread landlord updates
</p>
</div>
</div>
<span class="text-sm font-semibold text-blue-700">
<span class="dashboard-notification-count dashboard-notification-count--blue">
{unreadMessageCount}
</span>
</div>
<div class="mt-3">
<a
href="/tenant/messages"
class="text-xs font-semibold text-blue-600 hover:text-blue-500"
>
View messages
</a>
<div class="dashboard-notification-footer">
<a href="/tenant/messages">View messages</a>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
<div class="dashboard-notification-card">
<div class="dashboard-notification-card-inner">
<div class="dashboard-notification-icon-wrap">
<div class="dashboard-notification-icon dashboard-notification-icon--green">
<svg
class="h-5 w-5"
class="dashboard-notification-icon-svg"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -331,22 +364,20 @@ export default define.page(function TenantDashboard({ data }) {
</svg>
</div>
<div>
<p class="text-sm font-medium text-gray-900">
Maintenance
</p>
<p class="text-xs text-gray-500">
<p class="dashboard-row-label">Maintenance</p>
<p class="dashboard-row-sub">
Open or active requests
</p>
</div>
</div>
<span class="text-sm font-semibold text-emerald-700">
<span class="dashboard-notification-count dashboard-notification-count--green">
{openRequestCount}
</span>
</div>
<div class="mt-3">
<div class="dashboard-notification-footer">
<a
href="/tenant/maintenance"
class="text-xs font-semibold text-emerald-600 hover:text-emerald-500"
class="notification-link--green"
>
View requests
</a>
@ -358,40 +389,40 @@ export default define.page(function TenantDashboard({ data }) {
{/* Property Overview */}
{currentLease && (
<div class="bg-white shadow rounded-lg mb-8">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
<div class="dashboard-card dashboard-section-mb">
<div class="dashboard-card-body">
<h3 class="dashboard-section-title dashboard-section-title--mb">
Your Rental Property
</h3>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<div class="dashboard-property-grid">
<div>
<dt class="text-sm font-medium text-gray-500">Address</dt>
<dd class="mt-1 text-sm text-gray-900">
{currentLease.property_address || "Property Address"}
<dt class="dashboard-stat-label">Address</dt>
<dd class="dashboard-stat-value">
{currentProperty
? buildPropertyAddress(currentProperty) || "Property Address"
: currentLease.property_address || "Property Address"}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">
Monthly Rent
</dt>
<dd class="mt-1 text-sm text-gray-900">
${currentLease.rent?.toLocaleString() || "N/A"}
<dt class="dashboard-stat-label">Monthly Rent</dt>
<dd class="dashboard-stat-value">
${currentProperty?.monthly_rent?.toLocaleString() ||
currentLease.rent?.toLocaleString() ||
"N/A"}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">
Lease End Date
</dt>
<dd class="mt-1 text-sm text-gray-900">
<dt class="dashboard-stat-label">Lease End Date</dt>
<dd class="dashboard-stat-value">
{currentLease.end_date
? new Date(currentLease.end_date).toLocaleDateString()
: "N/A"}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Status</dt>
<dd class="mt-1">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 capitalize">
<dt class="dashboard-stat-label">Status</dt>
<dd>
<span class="dashboard-badge dashboard-badge--green capitalize">
{currentLease.lease_status || "active"}
</span>
</dd>
@ -402,16 +433,14 @@ export default define.page(function TenantDashboard({ data }) {
)}
{/* Quick Actions */}
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8">
<a
href="/tenant/rent/pay"
class="bg-white overflow-hidden shadow rounded-lg hover:shadow-md transition-shadow"
>
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="dashboard-stats-grid">
<a href="/tenant/rent/pay" class="dashboard-quick-action">
<div class="dashboard-card">
<div class="dashboard-card-body">
<div class="dashboard-stat-card">
<div class="dashboard-stat-icon-wrap">
<svg
class="h-6 w-6 text-green-400"
class="dashboard-stat-icon dashboard-stat-icon--green"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -424,29 +453,22 @@ export default define.page(function TenantDashboard({ data }) {
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
Pay Rent
</dt>
<dd class="text-lg font-medium text-gray-900">
Online Payment
</dd>
</dl>
<div class="dashboard-stat-content">
<dt class="dashboard-stat-label">Pay Rent</dt>
<dd class="dashboard-stat-value">Online Payment</dd>
</div>
</div>
</div>
</div>
</a>
<a
href="/tenant/maintenance"
class="bg-white overflow-hidden shadow rounded-lg hover:shadow-md transition-shadow"
>
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<a href="/tenant/maintenance" class="dashboard-quick-action">
<div class="dashboard-card">
<div class="dashboard-card-body">
<div class="dashboard-stat-card">
<div class="dashboard-stat-icon-wrap">
<svg
class="h-6 w-6 text-blue-400"
class="dashboard-stat-icon dashboard-stat-icon--blue"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -465,29 +487,22 @@ export default define.page(function TenantDashboard({ data }) {
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
Maintenance
</dt>
<dd class="text-lg font-medium text-gray-900">
Request Service
</dd>
</dl>
<div class="dashboard-stat-content">
<dt class="dashboard-stat-label">Maintenance</dt>
<dd class="dashboard-stat-value">Request Service</dd>
</div>
</div>
</div>
</div>
</a>
<a
href="/tenant/documents"
class="bg-white overflow-hidden shadow rounded-lg hover:shadow-md transition-shadow"
>
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<a href="/tenant/documents" class="dashboard-quick-action">
<div class="dashboard-card">
<div class="dashboard-card-body">
<div class="dashboard-stat-card">
<div class="dashboard-stat-icon-wrap">
<svg
class="h-6 w-6 text-purple-400"
class="dashboard-stat-icon dashboard-stat-icon--purple"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -500,29 +515,22 @@ export default define.page(function TenantDashboard({ data }) {
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
Documents
</dt>
<dd class="text-lg font-medium text-gray-900">
Lease & Forms
</dd>
</dl>
<div class="dashboard-stat-content">
<dt class="dashboard-stat-label">Documents</dt>
<dd class="dashboard-stat-value">Lease & Forms</dd>
</div>
</div>
</div>
</div>
</a>
<a
href="/tenant/messages"
class="bg-white overflow-hidden shadow rounded-lg hover:shadow-md transition-shadow"
>
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<a href="/tenant/messages" class="dashboard-quick-action">
<div class="dashboard-card">
<div class="dashboard-card-body">
<div class="dashboard-stat-card">
<div class="dashboard-stat-icon-wrap">
<svg
class="h-6 w-6 text-yellow-400"
class="dashboard-stat-icon dashboard-stat-icon--yellow"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -535,71 +543,58 @@ export default define.page(function TenantDashboard({ data }) {
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
Messages
</dt>
<dd class="text-lg font-medium text-gray-900">
<div class="dashboard-stat-content">
<dt class="dashboard-stat-label">Messages</dt>
<dd class="dashboard-stat-value">
{unreadMessageCount} Unread
</dd>
</dl>
</div>
</div>
</div>
</div>
</a>
</div>
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2">
<div class="dashboard-two-col">
{/* Recent Messages */}
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg leading-6 font-medium text-gray-900">
Recent Messages
</h3>
<a
href="/tenant/messages"
class="text-sm text-blue-600 hover:text-blue-500"
>
<div class="dashboard-card dashboard-card--no-overflow">
<div class="dashboard-card-body">
<div class="dashboard-section-header">
<h3 class="dashboard-section-title">Recent Messages</h3>
<a href="/tenant/messages" class="dashboard-section-link">
View all
</a>
</div>
<div class="space-y-4">
<div class="dashboard-list-spaced">
{messages && messages.length > 0
? (
messages.slice(0, 3).map((message) => (
<div key={message.id} class="flex items-start space-x-3">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
<span class="text-xs font-medium text-blue-700">
L
</span>
<div key={message.id} class="dashboard-message-row">
<div class="dashboard-message-avatar">
<span class="dashboard-message-avatar-text">L</span>
</div>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900">
Landlord
</p>
<p class="text-sm text-gray-500 truncate">
<div class="dashboard-message-body">
<p class="dashboard-message-from">Landlord</p>
<p class="dashboard-message-preview">
{message.subject}
</p>
<p class="text-xs text-gray-400">
<p class="dashboard-message-meta">
{new Date(message.created_on as number)
.toLocaleDateString()}
</p>
</div>
{!message.is_read && (
<div class="flex-shrink-0">
<div class="w-2 h-2 bg-blue-600 rounded-full"></div>
</div>
<div
class="dashboard-message-dot"
aria-hidden="true"
/>
)}
</div>
))
)
: (
<div class="text-center py-4">
<p class="text-sm text-gray-500">No messages yet</p>
<div class="dashboard-empty">
<p class="dashboard-empty-text">No messages yet</p>
</div>
)}
</div>
@ -607,48 +602,40 @@ export default define.page(function TenantDashboard({ data }) {
</div>
{/* Maintenance Requests */}
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg leading-6 font-medium text-gray-900">
<div class="dashboard-card dashboard-card--no-overflow">
<div class="dashboard-card-body">
<div class="dashboard-section-header">
<h3 class="dashboard-section-title">
Maintenance Requests
</h3>
<a
href="/tenant/maintenance"
class="text-sm text-blue-600 hover:text-blue-500"
>
<a href="/tenant/maintenance" class="dashboard-section-link">
View all
</a>
</div>
<div class="space-y-4">
<div class="dashboard-list-spaced">
{maintenanceRequests && maintenanceRequests.length > 0
? (
maintenanceRequests.slice(0, 3).map((request) => (
<div
key={request.id}
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div key={request.id} class="dashboard-row">
<div>
<p class="text-sm font-medium text-gray-900">
<p class="dashboard-row-label">
{request.request_type}
</p>
<p class="text-sm text-gray-500 capitalize">
<p class="dashboard-row-muted capitalize">
Priority: {request.priority}
</p>
<p class="text-xs text-gray-400">
<p class="dashboard-row-meta">
{new Date(request.created_on as number)
.toLocaleDateString()}
</p>
</div>
<div>
<span
class={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
request.status === "open"
? "bg-red-100 text-red-800"
class={request.status === "open"
? "dashboard-badge dashboard-badge--red-pill"
: request.status === "in_progress"
? "bg-yellow-100 text-yellow-800"
: "bg-green-100 text-green-800"
}`}
? "dashboard-badge dashboard-badge--yellow"
: "dashboard-badge dashboard-badge--green"}
>
{request.status?.replace("_", " ")}
</span>
@ -657,13 +644,13 @@ export default define.page(function TenantDashboard({ data }) {
))
)
: (
<div class="text-center py-4">
<p class="text-sm text-gray-500">
<div class="dashboard-empty">
<p class="dashboard-empty-text">
No maintenance requests
</p>
<a
href="/tenant/maintenance/new"
class="text-sm text-blue-600 hover:text-blue-500"
class="dashboard-empty-link"
>
Submit a request
</a>
@ -674,62 +661,48 @@ export default define.page(function TenantDashboard({ data }) {
</div>
{/* Recent Payments */}
<div class="bg-white shadow rounded-lg lg:col-span-2">
<div class="px-4 py-5 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg leading-6 font-medium text-gray-900">
Recent Payments
</h3>
<a
href="/tenant/payments"
class="text-sm text-blue-600 hover:text-blue-500"
>
<div class="dashboard-card dashboard-card--full-width">
<div class="dashboard-card-body">
<div class="dashboard-section-header">
<h3 class="dashboard-section-title">Recent Payments</h3>
<a href="/tenant/payments" class="dashboard-section-link">
View all
</a>
</div>
<div class="overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<div class="dashboard-table-wrap">
<table class="dashboard-table">
<thead>
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Description
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Amount
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th class="dashboard-table-th">Date</th>
<th class="dashboard-table-th">Description</th>
<th class="dashboard-table-th">Amount</th>
<th class="dashboard-table-th">Status</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tbody>
{recentTransactions && recentTransactions.length > 0
? (
recentTransactions.map((transaction) => (
<tr key={transaction.id}>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<td class="dashboard-table-td">
{new Date(transaction.created_on as number)
.toLocaleDateString()}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<td class="dashboard-table-td">
{transaction.payment_method}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<td class="dashboard-table-td">
${transaction.amount?.toLocaleString()}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<td class="dashboard-table-td">
<span
class={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
transaction.transaction_status === "completed"
? "bg-green-100 text-green-800"
class={transaction.transaction_status ===
"completed"
? "dashboard-badge dashboard-badge--green"
: transaction.transaction_status ===
"pending"
? "bg-yellow-100 text-yellow-800"
: "bg-red-100 text-red-800"
}`}
? "dashboard-badge dashboard-badge--yellow"
: "dashboard-badge dashboard-badge--red"}
>
{transaction.transaction_status}
</span>
@ -739,10 +712,7 @@ export default define.page(function TenantDashboard({ data }) {
)
: (
<tr>
<td
colspan={4}
class="px-6 py-4 text-center text-sm text-gray-500"
>
<td colspan={4} class="dashboard-table-empty">
No payment history yet
</td>
</tr>

View File

@ -13,6 +13,7 @@ import {
} from "../../../services/tenantInviteService.ts";
import { getUserById } from "../../../services/userService.ts";
import { getLeaseById } from "../../../services/leaseService.ts";
import { createMessage } from "../../../services/messageService.ts";
import LoginPurposeButton from "../../../islands/LoginPurposeButton.tsx";
import type {
Lease,
@ -132,9 +133,16 @@ export const handler = define.handlers({
await startGuestOnboarding(userId, "tenant");
const response = await fetch("/api/lease/request-approval", {
const cookieHeader = ctx.req.headers.get("Cookie") ?? "";
const requestUrl = new URL(ctx.req.url);
const apiUrl = `${requestUrl.origin}/api/lease/request-approval`;
const response = await fetch(apiUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
"Cookie": cookieHeader,
},
body: JSON.stringify({
invite_id: invite.id,
property_id: invite.property_id,
@ -150,6 +158,23 @@ export const handler = define.handlers({
});
}
const responseData = await response.json().catch(() => ({}));
const lease = responseData?.lease;
const leaseId = lease?.id ?? null;
if (invite.landlord_id && leaseId) {
await createMessage(
leaseId,
invite.landlord_id,
userId,
"notice",
"Lease documents – next steps",
"The landlord was notified of your lease request. A follow-up message will contain more information about your lease documents.",
null,
null,
);
}
await markRoleOnboarded(userId, "tenant");
return new Response("", {
@ -211,6 +236,8 @@ export default define.page(function TenantInvitePreview({ data }) {
const isApproved = approvalStatus === "approved";
const isRejected = approvalStatus === "rejected";
const hasLease = Boolean(lease);
// Allow "Start Lease" when no lease or when previous request was rejected (re-invite same property)
const canStartLease = !hasLease || isRejected;
if (!invite || !property) {
return (
@ -254,16 +281,22 @@ export default define.page(function TenantInvitePreview({ data }) {
const isLoggedIn = Boolean(user?.id);
const approvalSteps = [
{ label: "Request Lease", completed: hasLease },
{ label: "Request Lease", completed: hasLease && !isRejected },
{ label: "Landlord Review", completed: isApproved },
{ label: "Lease Active", completed: isApproved },
];
const getStepStatus = (stepIndex: number) => {
if (!hasLease) return stepIndex === 0 ? "current" : "pending";
if (isRejected) {
return stepIndex === 0
? "current"
: stepIndex === 1
? "error"
: "pending";
}
if (isPending) return stepIndex <= 1 ? "current" : "pending";
if (isApproved) return stepIndex <= 2 ? "current" : "completed";
if (isRejected) return stepIndex === 1 ? "error" : "completed";
return "pending";
};
@ -502,6 +535,20 @@ export default define.page(function TenantInvitePreview({ data }) {
lease request. You will receive an email once a
decision is made.
</p>
<p class="mt-3 text-amber-700 font-medium">
The landlord will send you the lease documents.
</p>
<p class="mt-2 text-sm text-amber-600">
A confirmation has been added to your{" "}
<a
href="/tenant/messages"
class="underline font-medium hover:text-amber-800"
>
Messages
</a>{" "}
– the landlord was notified and a follow-up message
will contain more information.
</p>
</div>
)}
{isApproved && (
@ -567,7 +614,7 @@ export default define.page(function TenantInvitePreview({ data }) {
</div>
</div>
)}
{!hasLease && (
{canStartLease && (
<div class="mt-4">
<p class="text-sm text-gray-500">
Click "Start Lease" to request landlord approval for this
@ -582,7 +629,7 @@ export default define.page(function TenantInvitePreview({ data }) {
What happens next
</h2>
<ul class="mt-4 space-y-2 text-sm text-gray-500">
{!hasLease && (
{canStartLease && (
<>
<li>- Click "Start Lease" to request landlord approval.</li>
<li>- Your landlord will review your request.</li>
@ -592,10 +639,15 @@ export default define.page(function TenantInvitePreview({ data }) {
{isPending && (
<>
<li>- Your landlord has been notified of your request.</li>
<li>- The landlord will send you the lease documents.</li>
<li>- Wait for landlord to review and approve/reject.</li>
<li>
- You will receive an email notification with a decision.
</li>
<li>
- Check your Messages for a confirmation and follow-up
details.
</li>
</>
)}
{isApproved && (
@ -609,7 +661,18 @@ export default define.page(function TenantInvitePreview({ data }) {
<>
<li>- Your lease request was declined.</li>
<li>- Contact your landlord if you have questions.</li>
<li>- You can request a new invite from your landlord.</li>
{canStartLease
? (
<li>
- You can submit a new request using the Start Lease
button above.
</li>
)
: (
<li>
- You can request a new invite from your landlord.
</li>
)}
</>
)}
</ul>
@ -624,18 +687,18 @@ export default define.page(function TenantInvitePreview({ data }) {
Ready to proceed?
</h2>
<p class="text-sm text-gray-500">
{!hasLease &&
{canStartLease &&
"Start your lease request, decline the invite, or come back later."}
{isPending &&
"Your request is pending landlord review. Check back here for updates."}
{isApproved &&
"Your lease is approved! Continue to your tenant portal."}
{isRejected &&
{isRejected && !canStartLease &&
"Your lease request was declined. Contact your landlord for more information."}
</p>
</div>
<div class="flex flex-wrap gap-3">
{!hasLease && isLoggedIn && (
{canStartLease && isLoggedIn && (
<form method="POST">
<input type="hidden" name="intent" value="start" />
<button

View File

@ -1,7 +1,10 @@
// routes/tenant/messages/[id]/index.tsx
import { define } from "../../../../utils.ts";
import { page } from "fresh";
import { getMessageThread } from "../../../../services/messageService.ts";
import {
getMessageThread,
markThreadAsRead,
} from "../../../../services/messageService.ts";
import { Message } from "../../../../db/interfaces.ts";
import { AlertMessage } from "../../maintenance/new/components/AlertMessage.tsx";
@ -29,9 +32,10 @@ export const handler = define.handlers({
});
}
// The root message is the first one in the thread array (due to the service's logic)
const rootMessageId = thread[0].parent_message_id || thread[0].id;
await markThreadAsRead(user_id, rootMessageId);
return page<MessageThreadPageData>({
thread,
currentUserId: user_id,
@ -117,12 +121,20 @@ export default define.page(function MessageThreadPage({ data }) {
<h1 class="text-3xl font-bold text-gray-900">Message Thread</h1>
<p class="mt-1 text-sm text-gray-500">Subject: {subject}</p>
</div>
<div class="flex space-x-3">
<a
href={`/tenant/messages/new?reply_to=${rootMessageId}`}
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
href="/tenant/messages"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 shadow-sm"
>
Reply
All messages
</a>
<a
href="/tenant/dashboard"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 shadow-sm"
>
Dashboard
</a>
</div>
</div>
</div>
</div>

View File

@ -3,13 +3,12 @@ import { page } from "fresh";
import { define } from "../../../utils.ts";
import { SessionCookie } from "../../auth/callback.ts";
import {
DashboardMessage,
deleteMessagesForUser,
getMessagesForUser,
getThreadsForUser,
markMessagesAsRead,
MessagePage,
ThreadPage,
ThreadSummary,
} from "../../../services/messageService.ts";
import LandlordMessageSelection from "../../../islands/LandlordMessageSelection.tsx";
import LandlordMessageThreads from "../../../islands/LandlordMessageThreads.tsx";
import TenantMessagesFilters from "../../../islands/TenantMessagesFilters.tsx";
@ -29,7 +28,7 @@ interface Filters {
interface MessagesPageData {
stateData: SessionCookie;
messagePage: MessagePage;
threadPage: ThreadPage;
filters: Filters;
error?: string;
}
@ -109,7 +108,7 @@ export const handler = define.handlers({
pageSize,
};
const messagePage = await getMessagesForUser(user_id, {
const threadPage = await getThreadsForUser(user_id, {
page: pageNumber,
pageSize,
sort: filters.sort,
@ -121,19 +120,19 @@ export const handler = define.handlers({
return page<MessagesPageData>({
stateData,
messagePage,
threadPage,
filters,
});
} catch (error) {
console.error("Error loading messages:", error);
return page<MessagesPageData>({
stateData: ctx.state.data as SessionCookie,
messagePage: {
messages: [],
threadPage: {
threads: [],
page: 1,
pageSize: 10,
total: 0,
totalMessages: 0,
totalThreads: 0,
totalPages: 1,
unread: 0,
},
@ -184,62 +183,59 @@ export const handler = define.handlers({
});
export default define.page(function TenantMessages({ data }) {
const { stateData, messagePage, filters, error } = data as MessagesPageData;
const { messages, page, total, totalPages } = messagePage;
const totalMessages = messagePage.totalMessages ?? total;
const unread = messagePage.unread ?? 0;
const { stateData, threadPage, filters, error } = data as MessagesPageData;
const { threads, page, totalPages } = threadPage;
const totalThreads = threadPage.totalThreads ?? threadPage.total;
const unread = threadPage.unread ?? 0;
const MessageRow = ({ message }: { message: DashboardMessage }) => {
const isUnread = !message.is_read;
const senderName = message.sender_id === stateData.user_id
const ThreadRow = ({ thread }: { thread: ThreadSummary }) => {
const msg = thread.latestMessage;
const senderName = msg.sender_id === stateData.user_id
? "You"
: "Landlord";
const isReply = !!message.parent_message_id;
: (msg.sender_name ?? "Landlord");
const preview = (msg.content_encrypted || msg.content || "").substring(
0,
100,
);
return (
<li
key={message.id}
class={`p-4 border-b border-gray-200 transition-colors ${
isUnread
thread.hasUnread
? "bg-blue-50 hover:bg-blue-100"
: "bg-white hover:bg-gray-50"
}`}
>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<input
type="checkbox"
name="message_id"
value={message.id}
class="h-4 w-4 text-blue-600 border-gray-300 rounded message-checkbox"
data-message-id={message.id}
/>
<div class="min-w-0 flex-1">
<div class="text-sm font-medium text-gray-900">
{isReply && (
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 mr-2">
Reply
<div class="text-sm font-medium text-gray-900 flex items-center gap-2 flex-wrap">
{thread.hasUnread && (
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-blue-600 text-white">
New!
</span>
)}
{thread.subject}
{thread.messageCount > 1 && (
<span class="text-xs text-gray-500">
({thread.messageCount} messages)
</span>
)}
{message.subject || "No Subject"}
</div>
<p class="text-sm text-gray-500 truncate">
<p class="text-sm text-gray-500 truncate mt-0.5">
<span class="font-semibold text-gray-700">{senderName}:</span>
{" "}
{(message.content_encrypted || message.content)?.substring(
0,
100,
) || "No content preview"}
...
{preview || "No content preview"}
{preview.length >= 100 ? "..." : ""}
</p>
</div>
</div>
<div class="flex items-center space-x-4">
<div class="flex items-center space-x-4 shrink-0">
<div class="text-sm text-gray-500">
{new Date(message.created_on!).toLocaleDateString()}
{msg.created_on
? new Date(msg.created_on).toLocaleDateString()
: "—"}
</div>
<a
href={`/tenant/messages/${message.id}`}
href={`/tenant/messages/${thread.rootMessageId}`}
class="text-sm text-blue-600 hover:text-blue-500"
>
View Thread
@ -269,6 +265,12 @@ export default define.page(function TenantMessages({ data }) {
>
New Message
</a>
<a
href="/tenant/dashboard"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 shadow-sm"
>
Dashboard
</a>
</div>
</div>
</div>
@ -301,27 +303,26 @@ export default define.page(function TenantMessages({ data }) {
</span>{" "}
to{" "}
<span class="font-medium">
{Math.min(page * filters.pageSize, totalMessages)}
{Math.min(page * filters.pageSize, totalThreads)}
</span>{" "}
of{" "}
<span class="font-medium">
{totalMessages}
{totalThreads}
</span>{" "}
results
threads
{unread > 0 && (
<span class="ml-2 text-blue-600 font-semibold">
({unread} unread)
</span>
)}
</p>
<LandlordMessageSelection />
</div>
{messages.length > 0
{threads.length > 0
? (
<ul class="divide-y divide-gray-200">
{messages.map((message) => (
<MessageRow key={message.id} message={message} />
{threads.map((thread) => (
<ThreadRow key={thread.rootMessageId} thread={thread} />
))}
</ul>
)

View File

@ -6,6 +6,8 @@ import {
createMessage,
getMessageById,
} from "../../../../services/messageService.ts";
import { getTenantInvitesByEmail } from "../../../../services/tenantInviteService.ts";
import { getUserById } from "../../../../services/userService.ts";
import { uploadToLocalBlobStorage } from "../../../../utils/blobStorage.ts";
import { Lease, Message } from "../../../../db/interfaces.ts";
import { FormField } from "../../maintenance/new/components/FormField.tsx";
@ -29,17 +31,6 @@ export const handler = define.handlers({
const url = new URL(ctx.req.url);
const replyToId = url.searchParams.get("reply_to");
const leases = await getLeasesByTenant(user_id);
const currentLease = leases?.[0];
const landlordId = currentLease?.owner_id;
if (!landlordId) {
return page<NewMessagePageData>({
error: "Could not find an active lease or landlord ID.",
initialSubject: "",
});
}
let replyToMessage: Message | null = null;
let initialSubject = "";
@ -47,9 +38,8 @@ export const handler = define.handlers({
const fetchedMessage = await getMessageById(replyToId);
if (fetchedMessage) {
replyToMessage = fetchedMessage;
// Use the root message ID for threading if it's a reply
const rootId = replyToMessage.parent_message_id || replyToMessage.id;
replyToMessage.id = rootId; // Overwrite ID to ensure reply goes to the root
replyToMessage.id = rootId;
const originalSubject = replyToMessage.subject || "No Subject";
initialSubject = originalSubject.startsWith("Re:")
@ -58,8 +48,34 @@ export const handler = define.handlers({
}
}
const leases = await getLeasesByTenant(user_id);
const currentLease = leases?.[0];
let landlordId = currentLease?.owner_id ?? null;
if (!landlordId && replyToMessage) {
const fromReply = replyToMessage.sender_id === user_id
? replyToMessage.recipient_id
: replyToMessage.sender_id;
landlordId = fromReply ?? null;
}
if (!landlordId) {
const user = await getUserById(user_id);
const invites = user?.email
? await getTenantInvitesByEmail(user.email)
: [];
landlordId = invites?.[0]?.landlord_id ?? null;
}
if (!landlordId) {
return page<NewMessagePageData>({
currentLease,
error:
"We couldn't find a recipient for your message. If you have a pending invite, use it to message your landlord.",
initialSubject: "",
});
}
return page<NewMessagePageData>({
currentLease: currentLease ?? undefined,
landlordId,
replyToMessage,
initialSubject,
@ -85,9 +101,10 @@ export const handler = define.handlers({
const parent_message_id = formData.get("parent_message_id") as string ||
null;
if (!lease_id || !recipient_id || !subject || !content) {
if (!recipient_id || !subject || !content) {
return new Response("Missing required fields", { status: 400 });
}
const leaseIdForMessage = lease_id?.trim() || null;
const attachments: { url: string; name: string; type: string }[] = [];
const files = formData.getAll("attachments") as File[];
@ -114,7 +131,7 @@ export const handler = define.handlers({
}
await createMessage(
lease_id,
leaseIdForMessage,
user_id,
recipient_id,
"general", // Default message type for tenant-initiated messages
@ -176,13 +193,13 @@ export default define.page(function NewTenantMessage({ data }) {
);
}
if (!currentLease || !landlordId) {
if (!landlordId) {
return (
<div class="min-h-screen bg-gray-50 tenant-route p-8">
<AlertMessage
type="error"
message={error ||
"You must have an active lease to send a message. Please contact support."}
"We couldn't find a recipient for your message. If you have a pending invite, use it to message your landlord."}
/>
<div class="mt-6">
<a
@ -214,11 +231,13 @@ export default define.page(function NewTenantMessage({ data }) {
<div class="p-6">
<form method="POST" encType="multipart/form-data">
{/* Hidden Fields */}
{currentLease && (
<input
type="hidden"
name="lease_id"
value={currentLease.id}
/>
)}
<input
type="hidden"
name="recipient_id"

View File

@ -0,0 +1,174 @@
// routes/tenant/onboarding/index.tsx
import { define } from "../../../utils.ts";
import { page } from "fresh";
import type { SessionCookie } from "../../auth/callback.ts";
import { getUserById, updateUser } from "../../../services/userService.ts";
import {
getOnboardingStatus,
markRoleOnboarded,
} from "../../../services/onboardingService.ts";
import { User } from "../../../db/interfaces.ts";
import { createPublicId } from "../../../helpers.ts";
export const handler = define.handlers({
async GET(ctx) {
const stateData = ctx.state.data as SessionCookie;
const { user_id } = stateData;
const publicId = stateData.user_id
? await createPublicId(stateData.user_id)
: "";
const status = await getOnboardingStatus(user_id);
if (status?.onboarded_tenant) {
return new Response("", {
status: 303,
headers: { Location: "/tenant/dashboard" },
});
}
const user = await getUserById(user_id);
return page({ stateData, user: user ? { ...user, id: publicId } : null });
},
async POST(ctx) {
const stateData = ctx.state.data as SessionCookie;
const { user_id } = stateData;
const publicId = stateData.user_id
? await createPublicId(stateData.user_id)
: "";
const status = await getOnboardingStatus(user_id);
if (status?.onboarded_tenant) {
return new Response("", {
status: 303,
headers: { Location: "/tenant/dashboard" },
});
}
const formData = await ctx.req.formData();
const name = (formData.get("name") || "").toString().trim();
const email = (formData.get("email") || "").toString().trim();
const phone = (formData.get("phone") || "").toString().trim();
if (!name || !email || !phone) {
const user = await getUserById(user_id);
return page({
stateData,
user: user ? { ...user, id: publicId } : null,
error: "Name, email, and phone are required.",
});
}
await updateUser(user_id, {
name,
email,
phone,
user_type: "tenant",
onboarded_tenant: true,
} as Partial<User>);
await markRoleOnboarded(user_id, "tenant");
return new Response(null, {
status: 303,
headers: { Location: "/tenant/dashboard" },
});
},
});
export default define.page(function TenantOnboarding({ data }) {
const { stateData: _stateData, user, error } = data as {
stateData: SessionCookie;
user: User | null;
error?: string;
};
return (
<div class="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8 tenant-route">
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
Complete Your Tenant Profile
</h2>
<p class="mt-2 text-center text-sm text-gray-600">
We just need a few details to get you started.
</p>
</div>
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
{error && (
<div class="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<form method="POST" class="space-y-6">
<div>
<label
for="name"
class="block text-sm font-medium text-gray-700"
>
Full Name *
</label>
<input
id="name"
name="name"
type="text"
required
value={user?.name || ""}
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
</div>
<div>
<label
for="email"
class="block text-sm font-medium text-gray-700"
>
Email Address *
</label>
<input
id="email"
name="email"
type="email"
required
value={user?.email || ""}
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
</div>
<div>
<label
for="phone"
class="block text-sm font-medium text-gray-700"
>
Phone Number *
</label>
<input
id="phone"
name="phone"
type="tel"
required
value={user?.phone || ""}
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
</div>
<div class="flex justify-end pt-6">
<button
type="submit"
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Continue to Dashboard
</button>
</div>
</form>
</div>
</div>
</div>
);
});

View File

@ -0,0 +1,25 @@
-- Clear messages and leases, then update messages.lease_id FK to ON DELETE SET NULL.
-- Run with: psql "<connection string>" -f scripts/clear-messages-leases-then-fix-fk.sql
-- WARNING: Permanently deletes all rows in messages and leases (and lease_documents).
-- Unlinks lease_id in tenant_invites, payment_transactions, and reminders.
BEGIN;
-- 1. Clear messages (references leases)
DELETE FROM messages;
-- 2. Unlink or remove other tables that reference leases so we can delete leases
DELETE FROM lease_documents;
UPDATE tenant_invites SET lease_id = NULL;
UPDATE payment_transactions SET lease_id = NULL;
UPDATE reminders SET lease_id = NULL;
-- 3. Clear leases
DELETE FROM leases;
-- 4. Update constraint so future lease deletes don't block on messages
ALTER TABLE messages DROP CONSTRAINT IF EXISTS messages_lease_id_leases_id_fk;
ALTER TABLE messages ADD CONSTRAINT messages_lease_id_leases_id_fk
FOREIGN KEY (lease_id) REFERENCES public.leases(id) ON DELETE SET NULL ON UPDATE NO ACTION;
COMMIT;

View File

@ -0,0 +1,7 @@
-- Allow lease delete/update without being blocked by messages.
-- Run if you still see: violates foreign key constraint "messages_lease_id_leases_id_fk"
-- Usage: psql "<your connection string>" -f scripts/fix-messages-lease-fk.sql
ALTER TABLE "messages" DROP CONSTRAINT IF EXISTS "messages_lease_id_leases_id_fk";
ALTER TABLE "messages" ADD CONSTRAINT "messages_lease_id_leases_id_fk"
FOREIGN KEY ("lease_id") REFERENCES "public"."leases"("id") ON DELETE SET NULL ON UPDATE NO ACTION;

View File

@ -49,16 +49,18 @@ function buildAuthState(ctx: Context<State>) {
const session = ctx.state.data;
const userId = session?.user_id?.trim() || null;
const sessionRoles = parseRoles(session?.role);
const userType = normalizeRole(ctx.state.user?.user_type ?? null);
const userTypeFromDb = normalizeRole(ctx.state.user?.user_type ?? null);
const userTypeFromSession = normalizeRole(session?.user_type ?? null);
const roleSet = new Set<Role>([
...sessionRoles,
...(userType ? [userType] : []),
...(userTypeFromDb ? [userTypeFromDb] : []),
...(userTypeFromSession ? [userTypeFromSession] : []),
]);
return {
isAuthenticated: !!userId,
userId,
userType,
userType: userTypeFromDb ?? userTypeFromSession,
roles: Array.from(roleSet),
};
}

193
services/expenseService.ts Normal file
View File

@ -0,0 +1,193 @@
// services/expenseService.ts
// Handle property expense management and reporting
import { and, eq, between, desc } from "drizzle-orm";
import { db } from "../db/db.ts";
import { property_expenses } from "../db/schema.ts";
// Use the type from the schema - this matches what Drizzle returns
export type PropertyExpense = typeof property_expenses.$inferSelect;
export interface ExpenseSummary {
total: number;
byCategory: Record<string, number>;
byMonth: Record<string, number>;
}
/**
* Create a new property expense record
*/
export async function createExpense(
property_id: string,
data: {
category: string;
amount: number;
description?: string;
expense_date: number;
payment_method?: string;
vendor?: string;
receipt_url?: string;
is_recurring?: boolean;
recurring_frequency?: string;
},
) {
const now = Date.now();
return await db.insert(property_expenses)
.values({
id: crypto.randomUUID(),
property_id,
category: data.category,
amount: data.amount,
description: data.description || null,
expense_date: data.expense_date,
payment_method: data.payment_method || null,
vendor: data.vendor || null,
receipt_url: data.receipt_url || null,
is_recurring: data.is_recurring || false,
recurring_frequency: data.recurring_frequency || null,
created_on: now,
updated_on: now,
})
.returning();
}
/**
* Get expense by ID
*/
export async function getExpenseById(expenseId: string): Promise<PropertyExpense | null> {
const result = await db.select()
.from(property_expenses)
.where(eq(property_expenses.id, expenseId));
return result[0] || null;
}
/**
* Get all expenses for a property
*/
export async function getExpensesByProperty(property_id: string): Promise<PropertyExpense[]> {
return await db.select()
.from(property_expenses)
.where(eq(property_expenses.property_id, property_id))
.orderBy(desc(property_expenses.expense_date));
}
/**
* Get expenses for a property within a date range
*/
export async function getExpensesByPropertyAndDateRange(
property_id: string,
startDate: number,
endDate: number,
): Promise<PropertyExpense[]> {
return await db.select()
.from(property_expenses)
.where(
and(
eq(property_expenses.property_id, property_id),
between(property_expenses.expense_date, startDate, endDate),
),
)
.orderBy(desc(property_expenses.expense_date));
}
/**
* Get expenses by category for a property
*/
export async function getExpensesByPropertyAndCategory(
property_id: string,
category: string,
): Promise<PropertyExpense[]> {
return await db.select()
.from(property_expenses)
.where(
and(
eq(property_expenses.property_id, property_id),
eq(property_expenses.category, category),
),
)
.orderBy(desc(property_expenses.expense_date));
}
/**
* Get recurring expenses for a property
*/
export async function getRecurringExpenses(property_id: string): Promise<PropertyExpense[]> {
return await db.select()
.from(property_expenses)
.where(
and(
eq(property_expenses.property_id, property_id),
eq(property_expenses.is_recurring, true),
),
)
.orderBy(desc(property_expenses.expense_date));
}
/**
* Update an expense
*/
export async function updateExpense(
expenseId: string,
updates: Partial<Omit<PropertyExpense, "id" | "property_id" | "created_on">>,
) {
const now = Date.now();
return await db.update(property_expenses)
.set({ ...updates, updated_on: now })
.where(eq(property_expenses.id, expenseId))
.returning();
}
/**
* Delete an expense
*/
export async function deleteExpense(expenseId: string) {
return await db.delete(property_expenses)
.where(eq(property_expenses.id, expenseId))
.returning();
}
/**
* Calculate expense summary for a property
*/
export async function getExpenseSummary(
property_id: string,
startDate?: number,
endDate?: number,
): Promise<ExpenseSummary> {
const expenses = startDate && endDate
? await getExpensesByPropertyAndDateRange(property_id, startDate, endDate)
: await getExpensesByProperty(property_id);
const total = expenses.reduce((sum, exp) => sum + (exp.amount || 0), 0);
const byCategory: Record<string, number> = {};
const byMonth: Record<string, number> = {};
for (const exp of expenses) {
const category = exp.category || "uncategorized";
byCategory[category] = (byCategory[category] || 0) + (exp.amount || 0);
if (exp.expense_date) {
const date = new Date(exp.expense_date);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
byMonth[monthKey] = (byMonth[monthKey] || 0) + (exp.amount || 0);
}
}
return { total, byCategory, byMonth };
}
/**
* Get total expenses for a property (all time or within date range)
*/
export async function getTotalExpenses(
property_id: string,
startDate?: number,
endDate?: number,
): Promise<number> {
const expenses = startDate && endDate
? await getExpensesByPropertyAndDateRange(property_id, startDate, endDate)
: await getExpensesByProperty(property_id);
return expenses.reduce((sum, exp) => sum + (exp.amount || 0), 0);
}

View File

@ -1,7 +1,7 @@
// services/landlordService.ts
// Handle landlord-specific operations, onboarding workflow, and dashboard data
import { and, desc, eq, or } from "drizzle-orm";
import { and, desc, eq, inArray, or } from "drizzle-orm";
import { db } from "../db/db.ts";
import {
leases,
@ -175,12 +175,24 @@ export async function getLandlordOnboardingStatus(
export async function getPendingLeaseApprovals(
landlord_id: string,
) {
// First get property IDs owned by this landlord
const landlordProperties = await db.select({ id: properties.id })
.from(properties)
.where(eq(properties.owner_id, landlord_id));
const propertyIds = landlordProperties.map((p) => p.id);
if (propertyIds.length === 0) {
return [];
}
// Query by property ownership since pending leases have owner_id=null
const pendingLeases = await db.select()
.from(leases)
.where(
and(
eq(leases.owner_id, landlord_id),
eq(leases.approval_status, "pending_approval"),
inArray(leases.property_id, propertyIds),
),
)
.orderBy(leases.approval_requested_at);
@ -189,10 +201,6 @@ export async function getPendingLeaseApprovals(
return [];
}
const propertyIds = pendingLeases
.map((lease) => lease.property_id)
.filter((id): id is string => Boolean(id));
const tenantIds = pendingLeases
.map((lease) => lease.tenant_id)
.filter((id): id is string => Boolean(id));
@ -213,7 +221,7 @@ export async function getPendingLeaseApprovals(
bathrooms: properties.bathrooms,
})
.from(properties)
.where(eq(properties.id, propertyIds[0]))
.where(inArray(properties.id, propertyIds))
: [];
const tenantsData = tenantIds.length > 0
@ -225,7 +233,7 @@ export async function getPendingLeaseApprovals(
phone: users.phone,
})
.from(users)
.where(eq(users.id, tenantIds[0]))
.where(inArray(users.id, tenantIds))
: [];
const propertyMap = new Map(
@ -235,11 +243,13 @@ export async function getPendingLeaseApprovals(
tenantsData.map((t) => [t.id, t]),
);
return pendingLeases.map((lease) => ({
const result = pendingLeases.map((lease) => ({
...lease,
property: lease.property_id ? propertyMap.get(lease.property_id) : null,
tenant: lease.tenant_id ? tenantMap.get(lease.tenant_id) : null,
}));
return result;
}
// Get landlord dashboard data

View File

@ -1,10 +1,11 @@
// services/leaseService.ts
// Handle lease management, creation, updates, and tenant relationships
import { and, eq } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import { db } from "../db/db.ts";
import { leases, tenant_invites } from "../db/schema.ts";
import { leases, properties, tenant_invites } from "../db/schema.ts";
import { Lease } from "../db/interfaces.ts";
import { getPropertyById } from "./propertyService.ts";
// export interface Lease {
// id: string;
@ -323,17 +324,35 @@ export async function requestLeaseApproval(
_invite_id: string,
) {
const now = Date.now();
// Fetch property to populate lease details
const property = await getPropertyById(property_id);
// Build property address from components
const propertyAddress = property
? [
property.address_1,
property.address_2,
property.city,
property.state,
property.zip,
]
.filter(Boolean)
.join(", ")
: "";
return await db.insert(leases)
.values({
id: crypto.randomUUID(),
title: `Lease for Property ${property_id}`,
title: property?.title || `Lease for Property ${property_id}`,
property_id,
property_address: propertyAddress,
tenant_id,
owner_id: null,
start_date: now,
end_date: now + 365 * 24 * 60 * 60 * 1000,
rent: null,
security_deposit: null,
rent: property?.monthly_rent || null,
security_deposit: property?.security_deposit || null,
lease_status: "draft",
approval_status: "pending_approval",
approval_requested_at: now,
@ -355,22 +374,33 @@ export async function requestLeaseApproval(
export async function approveLease(
lease_id: string,
landlord_id: string,
property_id?: string,
) {
const now = Date.now();
// If property_id provided, verify landlord owns the property
// (pending leases have owner_id=null, so we check property ownership)
if (property_id) {
const property = await db.select({ owner_id: properties.owner_id })
.from(properties)
.where(eq(properties.id, property_id))
.limit(1);
if (!property || property[0]?.owner_id !== landlord_id) {
return [];
}
}
return await db.update(leases)
.set({
approval_status: "approved",
approved_at: now,
approved_by: landlord_id,
owner_id: landlord_id, // Set owner_id on approval
lease_status: "active",
updated_on: now,
})
.where(
and(
eq(leases.id, lease_id),
eq(leases.owner_id, landlord_id),
),
)
.where(eq(leases.id, lease_id))
.returning();
}
@ -379,32 +409,58 @@ export async function rejectLease(
lease_id: string,
landlord_id: string,
rejection_reason?: string,
property_id?: string,
) {
const now = Date.now();
// If property_id provided, verify landlord owns the property
// (pending leases have owner_id=null, so we check property ownership)
if (property_id) {
const property = await db.select({ owner_id: properties.owner_id })
.from(properties)
.where(eq(properties.id, property_id))
.limit(1);
if (!property || property[0]?.owner_id !== landlord_id) {
return [];
}
}
return await db.update(leases)
.set({
approval_status: "rejected",
rejection_reason: rejection_reason || null,
updated_on: now,
})
.where(
and(
eq(leases.id, lease_id),
eq(leases.owner_id, landlord_id),
),
)
.where(eq(leases.id, lease_id))
.returning();
}
// Get pending lease approvals for a landlord
// Queries by property ownership since pending leases have owner_id=null
export async function getPendingApprovals(landlord_id: string) {
return await db.select()
// First get property IDs owned by this landlord
const landlordProperties = await db.select({ id: properties.id })
.from(properties)
.where(eq(properties.owner_id, landlord_id));
const propertyIds = landlordProperties.map((p) => p.id);
if (propertyIds.length === 0) {
return [];
}
// Then get pending leases for those properties
const pendingLeases = await db.select()
.from(leases)
.where(
and(
eq(leases.owner_id, landlord_id),
eq(leases.approval_status, "pending_approval"),
// Only return leases for properties owned by this landlord
inArray(leases.property_id, propertyIds),
),
)
.orderBy(leases.approval_requested_at);
return pendingLeases;
}

View File

@ -38,6 +38,24 @@ export interface MessagePage {
unread: number;
}
export interface ThreadSummary {
rootMessageId: string;
subject: string;
latestMessage: DashboardMessage;
hasUnread: boolean;
messageCount: number;
}
export interface ThreadPage {
threads: ThreadSummary[];
page: number;
pageSize: number;
total: number;
totalThreads: number;
totalPages: number;
unread: number;
}
function formatPropertyAddress(
property: {
address_1?: string | null;
@ -299,6 +317,225 @@ export async function getMessagesForUser(
};
}
const THREAD_FETCH_LIMIT = 2000;
export async function getThreadsForUser(
user_id: string,
options: MessageQueryOptions = {},
): Promise<ThreadPage> {
const { page, pageSize, sort, status, type, direction, search } =
normalizeOptions(options);
const directionClause = direction === "sent"
? eq(messages.sender_id, user_id)
: direction === "received"
? eq(messages.recipient_id, user_id)
: or(eq(messages.sender_id, user_id), eq(messages.recipient_id, user_id));
const clauses = [directionClause];
if (type !== "all") {
clauses.push(eq(messages.message_type, type));
}
if (search) {
const pattern = `%${search}%`;
clauses.push(or(
ilike(messages.subject, pattern),
ilike(messages.content, pattern),
));
}
const whereClause = clauses.length > 1 ? and(...clauses) : clauses[0];
const rawMessages = await db.select()
.from(messages)
.where(whereClause)
.orderBy(
sort === "oldest" ? asc(messages.created_on) : desc(messages.created_on),
)
.limit(THREAD_FETCH_LIMIT) as unknown as Message[];
type Group = {
rootId: string;
subject: string;
list: Message[];
hasUnread: boolean;
};
const groupMap = new Map<string, Group>();
for (const m of rawMessages) {
const rootId = m.parent_message_id || m.id;
const existing = groupMap.get(rootId);
const isUnread = m.recipient_id === user_id && !m.is_read;
if (!existing) {
const rootSubject = rootId === m.id ? (m.subject || "No subject") : "";
groupMap.set(rootId, {
rootId,
subject: rootId === m.id ? (m.subject || "No subject") : "",
list: [m],
hasUnread: isUnread,
});
} else {
if (rootId === m.id) existing.subject = m.subject || "No subject";
existing.list.push(m);
if (isUnread) existing.hasUnread = true;
}
}
for (const g of groupMap.values()) {
if (!g.subject && g.list.length > 0) {
const rootMsg = g.list.find((m) => m.id === g.rootId) ?? g.list[0];
g.subject = rootMsg.subject || "No subject";
}
}
let threadList = Array.from(groupMap.values());
if (status === "unread") {
threadList = threadList.filter((t) => t.hasUnread);
} else if (status === "read") {
threadList = threadList.filter((t) => !t.hasUnread);
}
threadList.sort((a, b) => {
const aLatest = Math.max(...a.list.map((m) => m.created_on ?? 0));
const bLatest = Math.max(...b.list.map((m) => m.created_on ?? 0));
return sort === "oldest" ? aLatest - bLatest : bLatest - aLatest;
});
const totalThreads = threadList.length;
const totalPages = Math.max(1, Math.ceil(totalThreads / pageSize));
const offset = (page - 1) * pageSize;
const pageThreads = threadList.slice(offset, offset + pageSize);
const unreadResult = await db.select({
count: sql<number>`count(*)`,
})
.from(messages)
.where(and(
eq(messages.recipient_id, user_id),
eq(messages.is_read, false),
));
const unread = unreadResult[0]?.count ?? 0;
const userIds = Array.from(
new Set(
pageThreads.flatMap((t) =>
t.list.flatMap((m) => [m.sender_id, m.recipient_id])
).filter(Boolean) as string[],
),
);
const userMap = new Map<string, { name: string | null }>();
if (userIds.length > 0) {
const userRows = await db.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, userIds));
userRows.forEach((row) => userMap.set(row.id, { name: row.name }));
}
const leaseIds = Array.from(
new Set(
pageThreads.flatMap((t) =>
t.list.map((m) => m.lease_id).filter(Boolean) as string[]
),
),
);
const leaseMap = new Map<
string,
{ property_id: string | null; property_address: string | null }
>();
if (leaseIds.length > 0) {
const leaseRows = await db.select({
id: leases.id,
property_id: leases.property_id,
property_address: leases.property_address,
})
.from(leases)
.where(inArray(leases.id, leaseIds));
leaseRows.forEach((row) =>
leaseMap.set(row.id, {
property_id: row.property_id,
property_address: row.property_address,
})
);
}
const propertyIds = Array.from(
new Set(
Array.from(leaseMap.values())
.map((l) => l.property_id)
.filter(Boolean) as string[],
),
);
const propertyMap = new Map<string, string>();
if (propertyIds.length > 0) {
const propertyRows = await db.select({
id: properties.id,
address_1: properties.address_1,
address_2: properties.address_2,
city: properties.city,
state: properties.state,
zip: properties.zip,
})
.from(properties)
.where(inArray(properties.id, propertyIds));
propertyRows.forEach((p) =>
propertyMap.set(p.id, formatPropertyAddress(p))
);
}
const threads: ThreadSummary[] = pageThreads.map((t) => {
const latest = t.list.reduce((prev, m) =>
(m.created_on ?? 0) > (prev.created_on ?? 0) ? m : prev
);
const sender = latest.sender_id ? userMap.get(latest.sender_id) : null;
const recipient = latest.recipient_id
? userMap.get(latest.recipient_id)
: null;
const lease = latest.lease_id ? leaseMap.get(latest.lease_id) : null;
const propertyAddress = lease?.property_address ||
(lease?.property_id ? propertyMap.get(lease.property_id) : "") || "";
const latestEnriched: DashboardMessage = {
...latest,
sender_name: sender?.name ?? "Sender",
recipient_name: recipient?.name ?? "Recipient",
property_address: propertyAddress,
created_at: latest.created_on ?? null,
};
return {
rootMessageId: t.rootId,
subject: t.subject,
latestMessage: latestEnriched,
hasUnread: t.hasUnread,
messageCount: t.list.length,
};
});
return {
threads,
page,
pageSize,
total: totalThreads,
totalThreads,
totalPages,
unread,
};
}
export async function markThreadAsRead(
user_id: string,
root_message_id: string,
): Promise<void> {
const thread = await getMessageThread(root_message_id);
if (!thread || thread.length === 0) return;
const ids = thread
.filter((m) => m.recipient_id === user_id && !m.is_read)
.map((m) => m.id);
if (ids.length > 0) {
await markMessagesAsRead(user_id, ids);
}
}
// Get unread messages for a user
export async function getUnreadMessages(user_id: string) {
return await db.select()

View File

@ -58,8 +58,8 @@ export async function startGuestOnboarding(
return { nextUrl: "/landlord/onboarding/profile" };
}
// tenant
return { nextUrl: "/tenant/dashboard" };
// tenant: send to tenant onboarding to collect basic info first
return { nextUrl: "/tenant/onboarding" };
}
export async function getOnboardingStatus(

View File

@ -0,0 +1,184 @@
// services/rentChangeService.ts
// Handle rent change history tracking for properties
import { eq, desc } from "drizzle-orm";
import { db } from "../db/db.ts";
import { rent_changes, properties } from "../db/schema.ts";
// Use the type from the schema
export type RentChange = typeof rent_changes.$inferSelect;
/**
* Create a new rent change record
* Also updates the property's monthly_rent to the new value
*/
export async function createRentChange(
property_id: string,
effective_date: number,
monthly_rent: number,
options: {
reason?: string;
created_by?: string;
} = {},
) {
// First, get the current rent from the property
const propertyResult = await db.select({
monthly_rent: properties.monthly_rent,
})
.from(properties)
.where(eq(properties.id, property_id))
.limit(1);
const previous_rent = propertyResult[0]?.monthly_rent || null;
const now = Date.now();
// Create the rent change record
const result = await db.insert(rent_changes)
.values({
id: crypto.randomUUID(),
property_id,
effective_date,
monthly_rent,
previous_rent,
reason: options.reason || null,
created_by: options.created_by || null,
created_on: now,
})
.returning();
// Update the property with the new rent
await db.update(properties)
.set({ monthly_rent, updated_on: now })
.where(eq(properties.id, property_id));
return result;
}
/**
* Get all rent changes for a property, ordered by effective date (newest first)
*/
export async function getRentChangesByProperty(property_id: string): Promise<RentChange[]> {
return await db.select()
.from(rent_changes)
.where(eq(rent_changes.property_id, property_id))
.orderBy(desc(rent_changes.effective_date));
}
/**
* Get the current rent for a property (most recent effective date that has passed)
*/
export async function getCurrentRentForProperty(property_id: string): Promise<number | null> {
const now = Date.now();
const result = await db.select()
.from(rent_changes)
.where(eq(rent_changes.property_id, property_id))
.orderBy(desc(rent_changes.effective_date))
.limit(1);
// If there are rent changes, return the most recent one
if (result.length > 0) {
return result[0].monthly_rent;
}
// Otherwise, fall back to the property's base monthly_rent
const propertyResult = await db.select({
monthly_rent: properties.monthly_rent,
})
.from(properties)
.where(eq(properties.id, property_id))
.limit(1);
return propertyResult[0]?.monthly_rent || null;
}
/**
* Get rent change by ID
*/
export async function getRentChangeById(changeId: string): Promise<RentChange | null> {
const result = await db.select()
.from(rent_changes)
.where(eq(rent_changes.id, changeId));
return result[0] || null;
}
/**
* Get upcoming rent changes for a property (effective_date is in the future)
*/
export async function getUpcomingRentChanges(property_id: string): Promise<RentChange[]> {
const now = Date.now();
return await db.select()
.from(rent_changes)
.where(eq(rent_changes.property_id, property_id))
.orderBy(desc(rent_changes.effective_date));
}
/**
* Get rent history for a property including current rent from the property record
*/
export async function getRentHistory(property_id: string): Promise<Array<{
effective_date: number;
monthly_rent: number;
previous_rent: number | null;
reason: string | null;
is_current: boolean;
}>> {
// Get the property's current rent
const propertyResult = await db.select({
monthly_rent: properties.monthly_rent,
updated_on: properties.updated_on,
})
.from(properties)
.where(eq(properties.id, property_id))
.limit(1);
const property = propertyResult[0];
// Get all rent changes
const changes = await getRentChangesByProperty(property_id);
// Build history including current property rent as the baseline
const history: Array<{
effective_date: number;
monthly_rent: number;
previous_rent: number | null;
reason: string | null;
is_current: boolean;
}> = [];
// Add current property rent as the starting point
if (property && property.monthly_rent) {
history.push({
effective_date: property.updated_on || Date.now(),
monthly_rent: property.monthly_rent,
previous_rent: null,
reason: "Initial rent",
is_current: true,
});
}
// Add all historical changes
for (const change of changes) {
history.push({
effective_date: change.effective_date || 0,
monthly_rent: change.monthly_rent || 0,
previous_rent: change.previous_rent,
reason: change.reason,
is_current: false,
});
}
// Sort by effective date descending (newest first)
history.sort((a, b) => b.effective_date - a.effective_date);
return history;
}
/**
* Delete a rent change record
*/
export async function deleteRentChange(changeId: string) {
return await db.delete(rent_changes)
.where(eq(rent_changes.id, changeId))
.returning();
}

View File

@ -34,7 +34,10 @@ function hasMatchingInviteEmail(
export async function createTenantInvite(
landlordId: string,
propertyId: string,
options: { availableOn?: number | null } = {},
options: {
availableOn?: number | null;
invitedEmails?: string[];
} = {},
) {
const existing = await db.select().from(tenant_invites).where(
and(
@ -46,12 +49,15 @@ export async function createTenantInvite(
return existing;
}
const now = Date.now();
const invitedEmails = options.invitedEmails ?? [];
return await db.insert(tenant_invites).values({
id: crypto.randomUUID(),
landlord_id: landlordId,
property_id: propertyId,
available_on: options.availableOn ?? null,
invited_emails: JSON.stringify([]),
invited_emails: JSON.stringify(
invitedEmails.map((e) => String(e).trim()).filter(Boolean),
),
created_on: now,
updated_on: now,
}).returning();
@ -90,16 +96,30 @@ export async function updateTenantInvite(
.returning();
}
/** Extract plain email from "Display Name <email@example.com>" or return trimmed input. */
function toPlainEmail(email: string): string {
const trimmed = email.trim();
const match = trimmed.match(/<([^>]+)>/);
return (match ? match[1].trim() : trimmed).toLowerCase();
}
export async function getTenantInvitesByEmail(email: string) {
const normalizedEmail = email.trim().toLowerCase();
if (!normalizedEmail) return [];
const normalizedEmail = toPlainEmail(email);
if (!normalizedEmail) {
return [];
}
// ILIKE substring match then exact filter (handles JSON array and comma-separated stored format).
const pattern = `%${normalizedEmail}%`;
const result = await db.select().from(tenant_invites).where(
ilike(tenant_invites.invited_emails, pattern),
);
return result.filter((invite) =>
hasMatchingInviteEmail(invite, normalizedEmail)
);
return result.filter((invite) => {
return hasMatchingInviteEmail(invite, normalizedEmail);
});
}
export async function removeTenantInviteEmail(inviteId: string, email: string) {

View File

@ -342,7 +342,7 @@ export const ROUTES = {
tenantMessages: "/tenant/messages",
tenantMaintenance: "/tenant/maintenance",
onboardingLandlord: "/onboarding/landlord",
onboardingTenant: "/onboarding/tenant",
onboardingTenant: "/tenant/onboarding",
authLogin: "/auth/login",
authLogout: "/auth/logout",
} as const;